nwp500-python 8.1.0__tar.gz → 8.1.2__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.2}/CHANGELOG.rst +3 -0
- {nwp500_python-8.1.0/src/nwp500_python.egg-info → nwp500_python-8.1.2}/PKG-INFO +1 -1
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/mqtt/client.py +74 -5
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/mqtt/periodic.py +0 -6
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/mqtt/reconnection.py +17 -2
- {nwp500_python-8.1.0 → nwp500_python-8.1.2/src/nwp500_python.egg-info}/PKG-INFO +1 -1
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500_python.egg-info/SOURCES.txt +2 -0
- nwp500_python-8.1.2/tests/test_mqtt_clean_session_resume.py +190 -0
- nwp500_python-8.1.2/tests/test_mqtt_reconnection_storm.py +447 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/.coveragerc +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/.github/RESOLVING_PR_COMMENTS.md +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/.github/copilot-instructions.md +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/.github/workflows/ci.yml +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/.github/workflows/release.yml +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/.gitignore +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/.pre-commit-config.yaml +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/.readthedocs.yml +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/AUTHORS.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/CONTRIBUTING.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/LICENSE.txt +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/Makefile +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/README.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/RELEASE.md +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/Makefile +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/_static/.gitignore +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/conf.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/explanation/advanced-features.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/explanation/architecture.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/explanation/index.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/how-to/authenticate.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/how-to/auto-recovery.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/how-to/diagnose-mqtt.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/how-to/home-assistant.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/how-to/index.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/how-to/maintenance.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/how-to/manage-units.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/how-to/monitor-status.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/how-to/optimize-tou.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/how-to/queue-commands.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/how-to/schedule-operation.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/how-to/track-energy.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/index.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/openapi.yaml +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/project/authors.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/project/changelog.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/project/contributing.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/project/history.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/project/license.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/reference/configuration.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/reference/enumerations.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/reference/index.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/reference/installation.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/reference/protocol/data_conversions.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/reference/protocol/device_features.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/reference/protocol/device_status.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/reference/protocol/error_codes.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/reference/protocol/mqtt_protocol.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/reference/protocol/quick_reference.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/reference/protocol/rest_api.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/reference/python_api/api_client.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/reference/python_api/auth_client.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/reference/python_api/cli.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/reference/python_api/events.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/reference/python_api/exceptions.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/reference/python_api/models.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/reference/python_api/mqtt_client.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/requirements.txt +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/docs/tutorials/getting-started.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/.ruff.toml +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/README.md +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/advanced/air_filter_reset.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/advanced/anti_legionella.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/advanced/auto_recovery.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/advanced/combined_callbacks.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/advanced/demand_response.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/advanced/device_capabilities.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/advanced/device_status_debug.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/advanced/energy_analytics.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/advanced/error_code_demo.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/advanced/firmware_payload_capture.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/advanced/mqtt_diagnostics.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/advanced/power_control.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/advanced/recirculation_control.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/advanced/reconnection_demo.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/advanced/reservation_schedule.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/advanced/simple_auto_recovery.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/advanced/token_restoration.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/advanced/tou_openei.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/advanced/tou_schedule.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/advanced/water_reservation.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/beginner/01_authentication.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/beginner/02_list_devices.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/beginner/03_get_status.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/beginner/04_set_temperature.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/intermediate/advanced_auth_patterns.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/intermediate/command_queue.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/intermediate/device_status_callback.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/intermediate/error_handling.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/intermediate/event_driven_control.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/intermediate/improved_auth.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/intermediate/legacy_auth_constructor.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/intermediate/mqtt_realtime_monitoring.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/intermediate/periodic_requests.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/intermediate/set_mode.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/intermediate/vacation_mode.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/mask.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/testing/periodic_device_info.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/testing/simple_periodic_info.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/testing/test_api_client.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/testing/test_mqtt_connection.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/testing/test_mqtt_messaging.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/examples/testing/test_periodic_minimal.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/pyproject.toml +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/scripts/README.md +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/scripts/bump_version.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/scripts/diagnose_mqtt_connection.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/scripts/extract_changelog.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/scripts/format.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/scripts/lint.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/scripts/setup-dev.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/scripts/validate_version.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/setup.cfg +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/setup.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/__init__.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/_base.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/api_client.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/auth.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/cli/__init__.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/cli/__main__.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/cli/commands.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/cli/handlers.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/cli/monitoring.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/cli/output_formatters.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/cli/rich_output.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/cli/token_storage.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/command_decorators.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/config.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/converters.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/device_capabilities.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/device_info_cache.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/encoding.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/enums.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/events.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/exceptions.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/factory.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/field_factory.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/models/__init__.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/models/_converters.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/models/device.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/models/energy.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/models/feature.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/models/mqtt_models.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/models/schedule.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/models/status.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/models/tou.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/mqtt/__init__.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/mqtt/command_queue.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/mqtt/connection.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/mqtt/control.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/mqtt/diagnostics.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/mqtt/state_tracker.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/mqtt/subscriptions.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/mqtt/utils.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/mqtt_events.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/openei.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/py.typed +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/reservations.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/temperature.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/topic_builder.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/unit_system.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500/utils.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500_python.egg-info/dependency_links.txt +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500_python.egg-info/entry_points.txt +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500_python.egg-info/not-zip-safe +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500_python.egg-info/requires.txt +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/src/nwp500_python.egg-info/top_level.txt +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/tests/conftest.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/tests/test_api_helpers.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/tests/test_auth.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/tests/test_bug_fixes.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/tests/test_cli_basic.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/tests/test_cli_commands.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/tests/test_command_decorators.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/tests/test_command_queue.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/tests/test_device_capabilities.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/tests/test_device_info_cache.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/tests/test_events.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/tests/test_exceptions.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/tests/test_model_converters.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/tests/test_models.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/tests/test_mqtt_client_init.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/tests/test_mqtt_hypothesis.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/tests/test_mqtt_reconnection.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/tests/test_multi_device.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/tests/test_openei.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/tests/test_reservations.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/tests/test_temperature_converters.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/tests/test_tou_api.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/tests/test_unit_switching.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/tests/test_utils.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.2}/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
|
|
@@ -350,8 +364,20 @@ class NavienMqttClient(EventEmitter):
|
|
|
350
364
|
)
|
|
351
365
|
)
|
|
352
366
|
|
|
353
|
-
#
|
|
354
|
-
|
|
367
|
+
# When the broker starts a clean session (session_present=False), all
|
|
368
|
+
# previous subscriptions have been dropped server-side. We must
|
|
369
|
+
# re-establish them before any device data can flow. This covers the
|
|
370
|
+
# common case where the AWS IoT SDK auto-reconnects internally before
|
|
371
|
+
# the MqttReconnectionHandler fires its own reconnect path — in that
|
|
372
|
+
# scenario the reconnect handler sees _connected==True and exits early,
|
|
373
|
+
# so resubscribe_all() would never be called without this block.
|
|
374
|
+
#
|
|
375
|
+
# When session_present=False, we must resubscribe before sending queued
|
|
376
|
+
# commands to ensure subscriptions are restored before device responses
|
|
377
|
+
# are processed. Use a composite coroutine to enforce ordering.
|
|
378
|
+
if not session_present and self._subscription_manager:
|
|
379
|
+
self._schedule_coroutine(self._handle_clean_session_resume())
|
|
380
|
+
elif self.config.enable_command_queue and self._command_queue:
|
|
355
381
|
self._schedule_coroutine(self._send_queued_commands_internal())
|
|
356
382
|
|
|
357
383
|
async def _send_queued_commands_internal(self) -> None:
|
|
@@ -363,6 +389,29 @@ class NavienMqttClient(EventEmitter):
|
|
|
363
389
|
self._connection_manager.publish, lambda: self._connected
|
|
364
390
|
)
|
|
365
391
|
|
|
392
|
+
async def _handle_clean_session_resume(self) -> None:
|
|
393
|
+
"""
|
|
394
|
+
Handle clean session reconnection with ordered resubscription.
|
|
395
|
+
|
|
396
|
+
When session_present=False (clean session), the broker has dropped all
|
|
397
|
+
subscriptions. This method ensures subscriptions are restored BEFORE
|
|
398
|
+
sending any queued commands, preventing commands from being processed
|
|
399
|
+
before their subscriptions are re-established.
|
|
400
|
+
"""
|
|
401
|
+
if not self._subscription_manager or not self._connection_manager:
|
|
402
|
+
return
|
|
403
|
+
|
|
404
|
+
if not self._connection_manager.connection:
|
|
405
|
+
return
|
|
406
|
+
|
|
407
|
+
self._subscription_manager.update_connection(
|
|
408
|
+
self._connection_manager.connection
|
|
409
|
+
)
|
|
410
|
+
await self._subscription_manager.resubscribe_all()
|
|
411
|
+
|
|
412
|
+
if self.config.enable_command_queue and self._command_queue:
|
|
413
|
+
await self._send_queued_commands_internal()
|
|
414
|
+
|
|
366
415
|
async def _active_reconnect(self) -> None:
|
|
367
416
|
"""
|
|
368
417
|
Actively trigger a reconnection attempt.
|
|
@@ -380,8 +429,13 @@ class NavienMqttClient(EventEmitter):
|
|
|
380
429
|
_logger.debug("Already connected, skipping reconnection")
|
|
381
430
|
return
|
|
382
431
|
|
|
432
|
+
if self._actively_reconnecting:
|
|
433
|
+
_logger.debug("Active reconnection already in progress, skipping")
|
|
434
|
+
return
|
|
435
|
+
|
|
383
436
|
_logger.info("Attempting active reconnection...")
|
|
384
437
|
|
|
438
|
+
self._actively_reconnecting = True
|
|
385
439
|
try:
|
|
386
440
|
# Ensure tokens are still valid
|
|
387
441
|
await self._auth_client.ensure_valid_token()
|
|
@@ -390,6 +444,9 @@ class NavienMqttClient(EventEmitter):
|
|
|
390
444
|
if self._connection_manager:
|
|
391
445
|
# Close old connection to stop SDK auto-reconnect and
|
|
392
446
|
# prevent two connections with the same client ID.
|
|
447
|
+
# _actively_reconnecting suppresses the
|
|
448
|
+
# on_connection_interrupted callback that closing triggers,
|
|
449
|
+
# preventing a competing backoff loop from being spawned.
|
|
393
450
|
_logger.debug("Recreating MQTT connection...")
|
|
394
451
|
try:
|
|
395
452
|
await self._connection_manager.close()
|
|
@@ -432,6 +489,8 @@ class NavienMqttClient(EventEmitter):
|
|
|
432
489
|
f"Error during active reconnection: {e}", exc_info=True
|
|
433
490
|
)
|
|
434
491
|
raise
|
|
492
|
+
finally:
|
|
493
|
+
self._actively_reconnecting = False
|
|
435
494
|
|
|
436
495
|
async def _deep_reconnect(self) -> None:
|
|
437
496
|
"""
|
|
@@ -451,13 +510,21 @@ class NavienMqttClient(EventEmitter):
|
|
|
451
510
|
_logger.debug("Already connected, skipping deep reconnection")
|
|
452
511
|
return
|
|
453
512
|
|
|
513
|
+
if self._actively_reconnecting:
|
|
514
|
+
_logger.debug("Active reconnection already in progress, skipping")
|
|
515
|
+
return
|
|
516
|
+
|
|
454
517
|
_logger.warning(
|
|
455
518
|
"Performing deep reconnection (full rebuild)... "
|
|
456
519
|
"This may take longer."
|
|
457
520
|
)
|
|
458
521
|
|
|
522
|
+
self._actively_reconnecting = True
|
|
459
523
|
try:
|
|
460
|
-
# Step 1: Clean up existing connection if any
|
|
524
|
+
# Step 1: Clean up existing connection if any.
|
|
525
|
+
# _actively_reconnecting suppresses the on_connection_interrupted
|
|
526
|
+
# callback that closing triggers, preventing a competing backoff
|
|
527
|
+
# loop from being spawned.
|
|
461
528
|
if self._connection_manager:
|
|
462
529
|
_logger.debug("Cleaning up old connection...")
|
|
463
530
|
try:
|
|
@@ -534,6 +601,8 @@ class NavienMqttClient(EventEmitter):
|
|
|
534
601
|
) as e:
|
|
535
602
|
_logger.error(f"Error during deep reconnection: {e}", exc_info=True)
|
|
536
603
|
raise
|
|
604
|
+
finally:
|
|
605
|
+
self._actively_reconnecting = False
|
|
537
606
|
|
|
538
607
|
async def connect(self) -> bool:
|
|
539
608
|
"""
|
|
@@ -173,12 +173,6 @@ class MqttPeriodicRequestManager:
|
|
|
173
173
|
await self._request_device_info(device)
|
|
174
174
|
elif request_type == PeriodicRequestType.DEVICE_STATUS:
|
|
175
175
|
await self._request_device_status(device)
|
|
176
|
-
else:
|
|
177
|
-
_logger.error(
|
|
178
|
-
"Unknown periodic request type: %s",
|
|
179
|
-
request_type,
|
|
180
|
-
)
|
|
181
|
-
break
|
|
182
176
|
|
|
183
177
|
_logger.debug(
|
|
184
178
|
"Sent periodic %s request for %s",
|
|
@@ -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
|
)
|
|
@@ -186,9 +186,11 @@ tests/test_events.py
|
|
|
186
186
|
tests/test_exceptions.py
|
|
187
187
|
tests/test_model_converters.py
|
|
188
188
|
tests/test_models.py
|
|
189
|
+
tests/test_mqtt_clean_session_resume.py
|
|
189
190
|
tests/test_mqtt_client_init.py
|
|
190
191
|
tests/test_mqtt_hypothesis.py
|
|
191
192
|
tests/test_mqtt_reconnection.py
|
|
193
|
+
tests/test_mqtt_reconnection_storm.py
|
|
192
194
|
tests/test_multi_device.py
|
|
193
195
|
tests/test_openei.py
|
|
194
196
|
tests/test_reservations.py
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Tests for MQTT client clean session reconnection handling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from nwp500.auth import AuthenticationResponse, AuthTokens, UserInfo
|
|
10
|
+
from nwp500.mqtt import NavienMqttClient
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture
|
|
14
|
+
def auth_client_with_valid_tokens():
|
|
15
|
+
"""Create an auth client with valid tokens."""
|
|
16
|
+
from nwp500.auth import NavienAuthClient
|
|
17
|
+
|
|
18
|
+
auth_client = NavienAuthClient("test@example.com", "password")
|
|
19
|
+
valid_tokens = AuthTokens(
|
|
20
|
+
id_token="test_id",
|
|
21
|
+
access_token="test_access",
|
|
22
|
+
refresh_token="test_refresh",
|
|
23
|
+
authentication_expires_in=3600,
|
|
24
|
+
access_key_id="test_key_id",
|
|
25
|
+
secret_key="test_secret_key",
|
|
26
|
+
session_token="test_session",
|
|
27
|
+
authorization_expires_in=3600,
|
|
28
|
+
)
|
|
29
|
+
auth_client._auth_response = AuthenticationResponse(
|
|
30
|
+
user_info=UserInfo(user_first_name="Test", user_last_name="User"),
|
|
31
|
+
tokens=valid_tokens,
|
|
32
|
+
)
|
|
33
|
+
return auth_client
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TestMqttCleanSessionResume:
|
|
37
|
+
"""Tests for clean session (session_present=False) reconnection handling."""
|
|
38
|
+
|
|
39
|
+
@pytest.mark.asyncio(loop_scope="function")
|
|
40
|
+
async def test_on_connection_resumed_with_clean_session_resubscribes(
|
|
41
|
+
self, auth_client_with_valid_tokens
|
|
42
|
+
):
|
|
43
|
+
"""Resubscribe when session_present=False on connection resume."""
|
|
44
|
+
client = NavienMqttClient(auth_client_with_valid_tokens)
|
|
45
|
+
|
|
46
|
+
# Mock the components
|
|
47
|
+
mock_subscription_manager = AsyncMock()
|
|
48
|
+
mock_subscription_manager.resubscribe_all = AsyncMock()
|
|
49
|
+
client._subscription_manager = mock_subscription_manager
|
|
50
|
+
|
|
51
|
+
mock_connection_manager = MagicMock()
|
|
52
|
+
mock_connection = MagicMock()
|
|
53
|
+
mock_connection_manager.connection = mock_connection
|
|
54
|
+
client._connection_manager = mock_connection_manager
|
|
55
|
+
|
|
56
|
+
# Mock the event emitter and diagnostics
|
|
57
|
+
client.emit = AsyncMock()
|
|
58
|
+
client._diagnostics = MagicMock()
|
|
59
|
+
client._diagnostics.record_connection_success = AsyncMock()
|
|
60
|
+
|
|
61
|
+
# Call with session_present=False (clean session)
|
|
62
|
+
client._on_connection_resumed_internal(
|
|
63
|
+
connection=mock_connection, return_code=0, session_present=False
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Give the scheduled coroutine time to run
|
|
67
|
+
import asyncio
|
|
68
|
+
|
|
69
|
+
await asyncio.sleep(0.1)
|
|
70
|
+
|
|
71
|
+
# Verify resubscribe_all was called
|
|
72
|
+
mock_subscription_manager.update_connection.assert_called_once_with(
|
|
73
|
+
mock_connection
|
|
74
|
+
)
|
|
75
|
+
# The resubscribe should be scheduled via _schedule_coroutine
|
|
76
|
+
# We need to wait for it or check the internal state
|
|
77
|
+
|
|
78
|
+
@pytest.mark.asyncio(loop_scope="function")
|
|
79
|
+
async def test_resubscribe_before_queued_commands(
|
|
80
|
+
self, auth_client_with_valid_tokens
|
|
81
|
+
):
|
|
82
|
+
"""Resubscribe completes before queued commands are sent."""
|
|
83
|
+
client = NavienMqttClient(auth_client_with_valid_tokens)
|
|
84
|
+
|
|
85
|
+
# Track call order
|
|
86
|
+
call_order = []
|
|
87
|
+
|
|
88
|
+
# Mock the components
|
|
89
|
+
mock_subscription_manager = MagicMock()
|
|
90
|
+
mock_subscription_manager.resubscribe_all = AsyncMock(
|
|
91
|
+
side_effect=lambda: call_order.append("resubscribe")
|
|
92
|
+
)
|
|
93
|
+
client._subscription_manager = mock_subscription_manager
|
|
94
|
+
|
|
95
|
+
mock_connection_manager = MagicMock()
|
|
96
|
+
mock_connection = MagicMock()
|
|
97
|
+
mock_connection_manager.connection = mock_connection
|
|
98
|
+
client._connection_manager = mock_connection_manager
|
|
99
|
+
|
|
100
|
+
# Mock command queue
|
|
101
|
+
client._command_queue = AsyncMock()
|
|
102
|
+
client.config.enable_command_queue = True
|
|
103
|
+
|
|
104
|
+
# Mock send_queued_commands to track it's called after resubscribe
|
|
105
|
+
original_send = client._send_queued_commands_internal
|
|
106
|
+
|
|
107
|
+
async def mock_send():
|
|
108
|
+
call_order.append("send_queued")
|
|
109
|
+
await original_send()
|
|
110
|
+
|
|
111
|
+
client._send_queued_commands_internal = mock_send
|
|
112
|
+
|
|
113
|
+
# Call the method
|
|
114
|
+
await client._handle_clean_session_resume()
|
|
115
|
+
|
|
116
|
+
# Verify subscription manager was updated with connection
|
|
117
|
+
mock_subscription_manager.update_connection.assert_called_once_with(
|
|
118
|
+
mock_connection
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Verify resubscribe was called before queued commands
|
|
122
|
+
assert call_order == ["resubscribe", "send_queued"]
|
|
123
|
+
|
|
124
|
+
@pytest.mark.asyncio(loop_scope="function")
|
|
125
|
+
async def test_skip_when_no_subscription_manager(
|
|
126
|
+
self, auth_client_with_valid_tokens
|
|
127
|
+
):
|
|
128
|
+
"""Return early if subscription_manager is None."""
|
|
129
|
+
client = NavienMqttClient(auth_client_with_valid_tokens)
|
|
130
|
+
client._subscription_manager = None
|
|
131
|
+
|
|
132
|
+
# Should not raise
|
|
133
|
+
await client._handle_clean_session_resume()
|
|
134
|
+
|
|
135
|
+
@pytest.mark.asyncio(loop_scope="function")
|
|
136
|
+
async def test_handle_clean_session_resume_skips_when_no_connection(
|
|
137
|
+
self, auth_client_with_valid_tokens
|
|
138
|
+
):
|
|
139
|
+
"""Return early if connection is None."""
|
|
140
|
+
client = NavienMqttClient(auth_client_with_valid_tokens)
|
|
141
|
+
|
|
142
|
+
mock_subscription_manager = MagicMock()
|
|
143
|
+
client._subscription_manager = mock_subscription_manager
|
|
144
|
+
|
|
145
|
+
mock_connection_manager = MagicMock()
|
|
146
|
+
mock_connection_manager.connection = None
|
|
147
|
+
client._connection_manager = mock_connection_manager
|
|
148
|
+
|
|
149
|
+
# Should not raise
|
|
150
|
+
await client._handle_clean_session_resume()
|
|
151
|
+
|
|
152
|
+
# Should not try to update connection
|
|
153
|
+
mock_subscription_manager.update_connection.assert_not_called()
|
|
154
|
+
|
|
155
|
+
@pytest.mark.asyncio(loop_scope="function")
|
|
156
|
+
async def test_on_connection_resumed_with_session_sends_queued_commands(
|
|
157
|
+
self, auth_client_with_valid_tokens
|
|
158
|
+
):
|
|
159
|
+
"""Send queued commands normally when session_present=True."""
|
|
160
|
+
client = NavienMqttClient(auth_client_with_valid_tokens)
|
|
161
|
+
|
|
162
|
+
# Mock the components
|
|
163
|
+
mock_command_queue = AsyncMock()
|
|
164
|
+
client._command_queue = mock_command_queue
|
|
165
|
+
client.config.enable_command_queue = True
|
|
166
|
+
|
|
167
|
+
# Mock the event emitter and diagnostics
|
|
168
|
+
client.emit = AsyncMock()
|
|
169
|
+
client._diagnostics = MagicMock()
|
|
170
|
+
client._diagnostics.record_connection_success = AsyncMock()
|
|
171
|
+
|
|
172
|
+
# Mock connection
|
|
173
|
+
mock_connection = MagicMock()
|
|
174
|
+
|
|
175
|
+
# Patch _send_queued_commands_internal to track if called
|
|
176
|
+
with patch.object(
|
|
177
|
+
client, "_send_queued_commands_internal", new_callable=AsyncMock
|
|
178
|
+
):
|
|
179
|
+
# Call with session_present=True (session resumed)
|
|
180
|
+
client._on_connection_resumed_internal(
|
|
181
|
+
connection=mock_connection, return_code=0, session_present=True
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# Give the scheduled coroutine time to run
|
|
185
|
+
import asyncio
|
|
186
|
+
|
|
187
|
+
await asyncio.sleep(0.1)
|
|
188
|
+
|
|
189
|
+
# Verify send_queued_commands_internal was scheduled
|
|
190
|
+
# (it will be called through _schedule_coroutine)
|