nwp500-python 8.0.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.0.0 → nwp500_python-8.1.1}/CHANGELOG.rst +67 -2
- {nwp500_python-8.0.0/src/nwp500_python.egg-info → nwp500_python-8.1.1}/PKG-INFO +1 -1
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/scripts/bump_version.py +77 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/device_info_cache.py +9 -6
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/encoding.py +30 -8
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/events.py +1 -1
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/mqtt/client.py +60 -18
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/mqtt/connection.py +38 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/mqtt/diagnostics.py +6 -1
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/mqtt/periodic.py +6 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/mqtt/reconnection.py +17 -2
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/mqtt/utils.py +3 -1
- {nwp500_python-8.0.0 → nwp500_python-8.1.1/src/nwp500_python.egg-info}/PKG-INFO +1 -1
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500_python.egg-info/SOURCES.txt +3 -0
- nwp500_python-8.1.1/tests/test_bug_fixes.py +198 -0
- nwp500_python-8.1.1/tests/test_mqtt_reconnection.py +130 -0
- nwp500_python-8.1.1/tests/test_mqtt_reconnection_storm.py +447 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/.coveragerc +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/.github/RESOLVING_PR_COMMENTS.md +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/.github/copilot-instructions.md +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/.github/workflows/ci.yml +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/.github/workflows/release.yml +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/.gitignore +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/.pre-commit-config.yaml +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/.readthedocs.yml +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/AUTHORS.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/CONTRIBUTING.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/LICENSE.txt +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/Makefile +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/README.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/RELEASE.md +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/Makefile +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/_static/.gitignore +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/conf.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/explanation/advanced-features.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/explanation/architecture.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/explanation/index.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/how-to/authenticate.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/how-to/auto-recovery.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/how-to/diagnose-mqtt.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/how-to/home-assistant.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/how-to/index.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/how-to/maintenance.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/how-to/manage-units.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/how-to/monitor-status.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/how-to/optimize-tou.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/how-to/queue-commands.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/how-to/schedule-operation.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/how-to/track-energy.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/index.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/openapi.yaml +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/project/authors.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/project/changelog.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/project/contributing.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/project/history.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/project/license.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/reference/configuration.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/reference/enumerations.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/reference/index.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/reference/installation.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/reference/protocol/data_conversions.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/reference/protocol/device_features.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/reference/protocol/device_status.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/reference/protocol/error_codes.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/reference/protocol/mqtt_protocol.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/reference/protocol/quick_reference.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/reference/protocol/rest_api.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/reference/python_api/api_client.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/reference/python_api/auth_client.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/reference/python_api/cli.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/reference/python_api/events.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/reference/python_api/exceptions.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/reference/python_api/models.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/reference/python_api/mqtt_client.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/requirements.txt +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/docs/tutorials/getting-started.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/.ruff.toml +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/README.md +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/advanced/air_filter_reset.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/advanced/anti_legionella.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/advanced/auto_recovery.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/advanced/combined_callbacks.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/advanced/demand_response.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/advanced/device_capabilities.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/advanced/device_status_debug.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/advanced/energy_analytics.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/advanced/error_code_demo.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/advanced/firmware_payload_capture.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/advanced/mqtt_diagnostics.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/advanced/power_control.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/advanced/recirculation_control.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/advanced/reconnection_demo.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/advanced/reservation_schedule.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/advanced/simple_auto_recovery.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/advanced/token_restoration.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/advanced/tou_openei.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/advanced/tou_schedule.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/advanced/water_reservation.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/beginner/01_authentication.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/beginner/02_list_devices.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/beginner/03_get_status.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/beginner/04_set_temperature.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/intermediate/advanced_auth_patterns.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/intermediate/command_queue.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/intermediate/device_status_callback.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/intermediate/error_handling.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/intermediate/event_driven_control.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/intermediate/improved_auth.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/intermediate/legacy_auth_constructor.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/intermediate/mqtt_realtime_monitoring.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/intermediate/periodic_requests.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/intermediate/set_mode.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/intermediate/vacation_mode.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/mask.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/testing/periodic_device_info.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/testing/simple_periodic_info.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/testing/test_api_client.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/testing/test_mqtt_connection.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/testing/test_mqtt_messaging.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/examples/testing/test_periodic_minimal.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/pyproject.toml +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/scripts/README.md +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/scripts/diagnose_mqtt_connection.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/scripts/extract_changelog.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/scripts/format.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/scripts/lint.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/scripts/setup-dev.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/scripts/validate_version.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/setup.cfg +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/setup.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/__init__.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/_base.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/api_client.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/auth.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/cli/__init__.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/cli/__main__.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/cli/commands.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/cli/handlers.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/cli/monitoring.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/cli/output_formatters.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/cli/rich_output.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/cli/token_storage.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/command_decorators.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/config.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/converters.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/device_capabilities.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/enums.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/exceptions.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/factory.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/field_factory.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/models/__init__.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/models/_converters.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/models/device.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/models/energy.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/models/feature.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/models/mqtt_models.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/models/schedule.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/models/status.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/models/tou.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/mqtt/__init__.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/mqtt/command_queue.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/mqtt/control.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/mqtt/state_tracker.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/mqtt/subscriptions.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/mqtt_events.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/openei.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/py.typed +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/reservations.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/temperature.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/topic_builder.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/unit_system.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500/utils.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500_python.egg-info/dependency_links.txt +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500_python.egg-info/entry_points.txt +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500_python.egg-info/not-zip-safe +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500_python.egg-info/requires.txt +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/src/nwp500_python.egg-info/top_level.txt +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/tests/conftest.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/tests/test_api_helpers.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/tests/test_auth.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/tests/test_cli_basic.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/tests/test_cli_commands.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/tests/test_command_decorators.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/tests/test_command_queue.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/tests/test_device_capabilities.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/tests/test_device_info_cache.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/tests/test_events.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/tests/test_exceptions.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/tests/test_model_converters.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/tests/test_models.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/tests/test_mqtt_client_init.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/tests/test_mqtt_hypothesis.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/tests/test_multi_device.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/tests/test_openei.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/tests/test_reservations.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/tests/test_temperature_converters.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/tests/test_tou_api.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/tests/test_unit_switching.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/tests/test_utils.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.1}/tox.ini +0 -0
|
@@ -2,8 +2,73 @@
|
|
|
2
2
|
Changelog
|
|
3
3
|
=========
|
|
4
4
|
|
|
5
|
-
Unreleased
|
|
6
|
-
|
|
5
|
+
Unreleased
|
|
6
|
+
==========
|
|
7
|
+
|
|
8
|
+
Version 8.1.1 (2026-05-18)
|
|
9
|
+
==========================
|
|
10
|
+
|
|
11
|
+
Version 8.1.0 (2026-05-16)
|
|
12
|
+
==========================
|
|
13
|
+
|
|
14
|
+
Bug Fixes
|
|
15
|
+
---------
|
|
16
|
+
- **Fix MQTT connection flapping after reconnect**: When ``_active_reconnect()``
|
|
17
|
+
created a new ``MqttConnection``, the old connection was never closed. The old
|
|
18
|
+
SDK connection's built-in auto-reconnect would eventually succeed, creating two
|
|
19
|
+
active connections sharing the same client ID. Because AWS IoT allows only one
|
|
20
|
+
connection per client ID, the broker would kick one off, triggering
|
|
21
|
+
``on_connection_interrupted`` and starting yet another reconnection — an
|
|
22
|
+
infinite connect/disconnect loop. Fixed by adding ``MqttConnection.close()``
|
|
23
|
+
(unconditional teardown regardless of ``_connected`` state) and calling it
|
|
24
|
+
before creating the replacement connection in both ``_active_reconnect()`` and
|
|
25
|
+
``_deep_reconnect()``.
|
|
26
|
+
|
|
27
|
+
- **Thread-safety race in ``ensure_device_info_cached``**: The ``future.done()``
|
|
28
|
+
check and ``future.set_result()`` were performed in the AWS SDK callback thread
|
|
29
|
+
without synchronisation, creating a race against the asyncio event loop thread.
|
|
30
|
+
Moved both operations inside a ``call_soon_threadsafe`` callback so they execute
|
|
31
|
+
atomically on the event loop thread.
|
|
32
|
+
|
|
33
|
+
- **ZeroDivisionError when ``deep_reconnect_threshold`` is 0**: Config validation
|
|
34
|
+
now clamps ``deep_reconnect_threshold`` to a minimum of 1, preventing a
|
|
35
|
+
``ZeroDivisionError`` in the exponential-backoff reconnection logic.
|
|
36
|
+
|
|
37
|
+
- **Reconnect counter never incremented**: ``total_reconnect_attempts`` in
|
|
38
|
+
diagnostics was not incremented on connection drops, so it always reported 0
|
|
39
|
+
despite active reconnections. Counter is now incremented on each
|
|
40
|
+
``on_connection_interrupted`` event.
|
|
41
|
+
|
|
42
|
+
- **``shortest_session_seconds`` not JSON-serialisable**: The diagnostics
|
|
43
|
+
``to_dict()`` method used ``float('inf')`` as the initial value for
|
|
44
|
+
``shortest_session_seconds``, which is not valid JSON. Changed to ``None``
|
|
45
|
+
so serialisation succeeds when no session has completed yet.
|
|
46
|
+
|
|
47
|
+
- **``wait_for()`` future not bound to running loop**: ``wait_for()`` created a
|
|
48
|
+
bare ``asyncio.Future()`` rather than
|
|
49
|
+
``asyncio.get_running_loop().create_future()``, which could bind the future to
|
|
50
|
+
a different loop in multi-loop test setups.
|
|
51
|
+
|
|
52
|
+
- **Reservation temperature validation was US-only**: ``build_reservation_entry``
|
|
53
|
+
validated set-point temperatures against hardcoded Fahrenheit bounds (95–150 °F)
|
|
54
|
+
regardless of the active unit system. Validation now uses the current unit system
|
|
55
|
+
context: 35–65 °C in metric mode, 95–150 °F in US mode. Celsius users previously
|
|
56
|
+
received spurious ``ValueError`` rejections for valid temperatures.
|
|
57
|
+
|
|
58
|
+
- **Malformed reservation data silently dropped**: ``build_reservation_entry`` now
|
|
59
|
+
logs a warning when reservation hex data contains unexpected trailing bytes
|
|
60
|
+
instead of silently dropping partial entries.
|
|
61
|
+
|
|
62
|
+
- **Unknown ``PeriodicRequestType`` silently ignored**: The periodic-request handler
|
|
63
|
+
now logs an error and breaks when it encounters an unknown request type instead of
|
|
64
|
+
doing nothing.
|
|
65
|
+
|
|
66
|
+
- **Memory leak in device info cache**: ``get_all_cached()`` only filtered expired
|
|
67
|
+
entries from its return value but left them in the cache dictionary. Expired
|
|
68
|
+
entries are now evicted during ``get_all_cached()`` to prevent unbounded growth.
|
|
69
|
+
|
|
70
|
+
Version 8.0.0 (2026-05-13)
|
|
71
|
+
===========================
|
|
7
72
|
|
|
8
73
|
Bug Fixes
|
|
9
74
|
---------
|
|
@@ -22,6 +22,8 @@ field is for the PyScaffold tool version, not the package version!
|
|
|
22
22
|
import re
|
|
23
23
|
import subprocess
|
|
24
24
|
import sys
|
|
25
|
+
from datetime import date
|
|
26
|
+
from pathlib import Path
|
|
25
27
|
|
|
26
28
|
|
|
27
29
|
def run_git_command(args: list) -> str:
|
|
@@ -140,6 +142,75 @@ def check_working_directory_clean() -> None:
|
|
|
140
142
|
sys.exit(1)
|
|
141
143
|
|
|
142
144
|
|
|
145
|
+
def update_changelog(version: str) -> None:
|
|
146
|
+
"""Insert a version heading into CHANGELOG.rst below the Unreleased section.
|
|
147
|
+
|
|
148
|
+
Transforms:
|
|
149
|
+
|
|
150
|
+
Unreleased
|
|
151
|
+
==========
|
|
152
|
+
|
|
153
|
+
<content...>
|
|
154
|
+
|
|
155
|
+
into:
|
|
156
|
+
|
|
157
|
+
Unreleased
|
|
158
|
+
==========
|
|
159
|
+
|
|
160
|
+
Version X.Y.Z (YYYY-MM-DD)
|
|
161
|
+
===========================
|
|
162
|
+
|
|
163
|
+
<content...>
|
|
164
|
+
"""
|
|
165
|
+
changelog_path = Path("CHANGELOG.rst")
|
|
166
|
+
if not changelog_path.exists():
|
|
167
|
+
print("Warning: CHANGELOG.rst not found, skipping changelog update.")
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
content = changelog_path.read_text(encoding="utf-8")
|
|
171
|
+
|
|
172
|
+
heading = f"Version {version} ({date.today().isoformat()})"
|
|
173
|
+
underline = "=" * len(heading)
|
|
174
|
+
version_block = f"{heading}\n{underline}\n"
|
|
175
|
+
|
|
176
|
+
# Match "Unreleased\n==========\n" (any number of = signs) followed by
|
|
177
|
+
# one or more blank lines, then insert the version block after them.
|
|
178
|
+
pattern = re.compile(
|
|
179
|
+
r"(Unreleased\n=+\n)" # group 1: Unreleased heading
|
|
180
|
+
r"(\n+)", # group 2: blank line(s) separator
|
|
181
|
+
re.MULTILINE,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
match = pattern.search(content)
|
|
185
|
+
if not match:
|
|
186
|
+
print(
|
|
187
|
+
"Warning: Could not find 'Unreleased' section in CHANGELOG.rst. "
|
|
188
|
+
"Skipping changelog update.",
|
|
189
|
+
file=sys.stderr,
|
|
190
|
+
)
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
# Insert the version block after the blank lines that follow "Unreleased"
|
|
194
|
+
new_content = (
|
|
195
|
+
content[: match.end()]
|
|
196
|
+
+ version_block
|
|
197
|
+
+ "\n"
|
|
198
|
+
+ content[match.end() :]
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
changelog_path.write_text(new_content, encoding="utf-8")
|
|
202
|
+
print(f"[OK] Updated CHANGELOG.rst with {heading}")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def commit_changelog(version: str) -> None:
|
|
206
|
+
"""Stage and commit the CHANGELOG.rst update."""
|
|
207
|
+
run_git_command(["add", "CHANGELOG.rst"])
|
|
208
|
+
run_git_command(
|
|
209
|
+
["commit", "-m", f"Update changelog for v{version}"]
|
|
210
|
+
)
|
|
211
|
+
print("[OK] Committed changelog update")
|
|
212
|
+
|
|
213
|
+
|
|
143
214
|
def create_tag(version: str, message: str = None) -> None:
|
|
144
215
|
"""Create a git tag for the version."""
|
|
145
216
|
tag_name = f"v{version}"
|
|
@@ -223,6 +294,11 @@ def main() -> None:
|
|
|
223
294
|
# Validate version progression
|
|
224
295
|
validate_version_progression(current_version, new_version)
|
|
225
296
|
|
|
297
|
+
# Update CHANGELOG.rst and commit, then create the tag
|
|
298
|
+
print("\nUpdating CHANGELOG.rst...")
|
|
299
|
+
update_changelog(new_version)
|
|
300
|
+
commit_changelog(new_version)
|
|
301
|
+
|
|
226
302
|
# Create the tag
|
|
227
303
|
print(f"\nCreating tag v{new_version}...")
|
|
228
304
|
create_tag(new_version)
|
|
@@ -230,6 +306,7 @@ def main() -> None:
|
|
|
230
306
|
print("\n[OK] Version bump complete!")
|
|
231
307
|
print("\nNext steps:")
|
|
232
308
|
print(f" 1. Push the tag: git push origin v{new_version}")
|
|
309
|
+
print(" (also push the changelog commit: git push origin HEAD)")
|
|
233
310
|
print(" 2. Build release: make build")
|
|
234
311
|
print(" 3. Test on TestPyPI: make publish-test")
|
|
235
312
|
print(" 4. Publish to PyPI: make publish")
|
|
@@ -137,12 +137,15 @@ class MqttDeviceInfoCache:
|
|
|
137
137
|
Dictionary mapping MAC addresses to DeviceFeature objects
|
|
138
138
|
"""
|
|
139
139
|
async with self._lock:
|
|
140
|
-
# Filter out expired entries
|
|
141
|
-
|
|
142
|
-
mac
|
|
143
|
-
for mac, (
|
|
144
|
-
if
|
|
145
|
-
|
|
140
|
+
# Filter out expired entries and purge them from cache
|
|
141
|
+
expired_keys = [
|
|
142
|
+
mac
|
|
143
|
+
for mac, (_, timestamp) in self._cache.items()
|
|
144
|
+
if self.is_expired(timestamp)
|
|
145
|
+
]
|
|
146
|
+
for mac in expired_keys:
|
|
147
|
+
del self._cache[mac]
|
|
148
|
+
return {mac: features for mac, (features, _) in self._cache.items()}
|
|
146
149
|
|
|
147
150
|
async def get_cache_info(
|
|
148
151
|
self,
|
|
@@ -8,11 +8,14 @@ These utilities are used by both the API client and MQTT client.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import logging
|
|
11
12
|
from collections.abc import Iterable
|
|
12
13
|
from numbers import Real
|
|
13
14
|
|
|
14
15
|
from .exceptions import ParameterValidationError, RangeValidationError
|
|
15
16
|
|
|
17
|
+
_logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
16
19
|
# MGPP Week Bitfield Encoding (from NaviLink APK KDEnum.MgppReservationWeek).
|
|
17
20
|
# Uses a single byte where bits 1-7 represent days; bit 0 is unused.
|
|
18
21
|
#
|
|
@@ -342,14 +345,18 @@ def decode_reservation_hex(hex_string: str) -> list[dict[str, int]]:
|
|
|
342
345
|
data = bytes.fromhex(hex_string)
|
|
343
346
|
reservations = []
|
|
344
347
|
|
|
348
|
+
if len(data) % 6 != 0:
|
|
349
|
+
_logger.warning(
|
|
350
|
+
"Reservation hex data length %d is not a multiple of 6; "
|
|
351
|
+
"trailing %d bytes will be ignored",
|
|
352
|
+
len(data),
|
|
353
|
+
len(data) % 6,
|
|
354
|
+
)
|
|
355
|
+
|
|
345
356
|
# Process 6 bytes at a time
|
|
346
|
-
for i in range(0, len(data), 6):
|
|
357
|
+
for i in range(0, len(data) - (len(data) % 6), 6):
|
|
347
358
|
chunk = data[i : i + 6]
|
|
348
359
|
|
|
349
|
-
# Ensure we have a full 6-byte entry
|
|
350
|
-
if len(chunk) != 6:
|
|
351
|
-
break
|
|
352
|
-
|
|
353
360
|
# Skip empty entries (all zeros)
|
|
354
361
|
if all(b == 0 for b in chunk):
|
|
355
362
|
continue
|
|
@@ -425,11 +432,26 @@ def build_reservation_entry(
|
|
|
425
432
|
"""
|
|
426
433
|
# Import here to avoid circular import
|
|
427
434
|
from .models import preferred_to_half_celsius
|
|
435
|
+
from .unit_system import get_unit_system
|
|
436
|
+
|
|
437
|
+
# Read unit system once to keep min/max bounds consistent
|
|
438
|
+
unit_system = get_unit_system()
|
|
428
439
|
|
|
429
440
|
# Use device-provided limits if available, otherwise use defaults
|
|
430
|
-
#
|
|
431
|
-
|
|
432
|
-
|
|
441
|
+
# in the user's preferred unit system.
|
|
442
|
+
if temperature_min is not None:
|
|
443
|
+
min_temp = temperature_min
|
|
444
|
+
elif unit_system == "metric":
|
|
445
|
+
min_temp = 35.0 # ~35°C
|
|
446
|
+
else:
|
|
447
|
+
min_temp = 95.0 # 95°F
|
|
448
|
+
|
|
449
|
+
if temperature_max is not None:
|
|
450
|
+
max_temp = temperature_max
|
|
451
|
+
elif unit_system == "metric":
|
|
452
|
+
max_temp = 65.0 # ~65°C
|
|
453
|
+
else:
|
|
454
|
+
max_temp = 150.0 # 150°F
|
|
433
455
|
|
|
434
456
|
if not 0 <= hour <= 23:
|
|
435
457
|
raise RangeValidationError(
|
|
@@ -396,7 +396,7 @@ class EventEmitter:
|
|
|
396
396
|
current_temp = temperature_event.new_temperature
|
|
397
397
|
"""
|
|
398
398
|
future: asyncio.Future[tuple[tuple[Any, ...], dict[str, Any]]] = (
|
|
399
|
-
asyncio.
|
|
399
|
+
asyncio.get_running_loop().create_future()
|
|
400
400
|
)
|
|
401
401
|
|
|
402
402
|
def handler(*args: Any, **kwargs: Any) -> None:
|
|
@@ -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
|
|
@@ -371,26 +385,40 @@ class NavienMqttClient(EventEmitter):
|
|
|
371
385
|
reconnect instead of passively waiting for AWS IoT SDK.
|
|
372
386
|
|
|
373
387
|
Note: This creates a new connection while preserving subscriptions
|
|
374
|
-
and configuration.
|
|
388
|
+
and configuration. The old connection is closed first to prevent
|
|
389
|
+
its SDK auto-reconnect from creating a competing connection with
|
|
390
|
+
the same client ID (which causes the broker to kick one off,
|
|
391
|
+
leading to an infinite connect/disconnect loop).
|
|
375
392
|
"""
|
|
376
393
|
if self._connected:
|
|
377
394
|
_logger.debug("Already connected, skipping reconnection")
|
|
378
395
|
return
|
|
379
396
|
|
|
397
|
+
if self._actively_reconnecting:
|
|
398
|
+
_logger.debug("Active reconnection already in progress, skipping")
|
|
399
|
+
return
|
|
400
|
+
|
|
380
401
|
_logger.info("Attempting active reconnection...")
|
|
381
402
|
|
|
403
|
+
self._actively_reconnecting = True
|
|
382
404
|
try:
|
|
383
405
|
# Ensure tokens are still valid
|
|
384
406
|
await self._auth_client.ensure_valid_token()
|
|
385
407
|
|
|
386
408
|
# If we have a connection manager, try to reconnect using it
|
|
387
409
|
if self._connection_manager:
|
|
388
|
-
#
|
|
389
|
-
#
|
|
410
|
+
# Close old connection to stop SDK auto-reconnect and
|
|
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.
|
|
390
415
|
_logger.debug("Recreating MQTT connection...")
|
|
416
|
+
try:
|
|
417
|
+
await self._connection_manager.close()
|
|
418
|
+
except (AwsCrtError, RuntimeError) as e:
|
|
419
|
+
_logger.debug(f"Old connection cleanup (benign): {e}")
|
|
391
420
|
|
|
392
421
|
# Create a new connection manager with same config
|
|
393
|
-
old_connection_manager = self._connection_manager
|
|
394
422
|
self._connection_manager = MqttConnection(
|
|
395
423
|
config=self.config,
|
|
396
424
|
auth_client=self._auth_client,
|
|
@@ -415,9 +443,6 @@ class NavienMqttClient(EventEmitter):
|
|
|
415
443
|
|
|
416
444
|
_logger.info("Active reconnection successful")
|
|
417
445
|
else:
|
|
418
|
-
# Restore old connection manager and connection reference
|
|
419
|
-
self._connection_manager = old_connection_manager
|
|
420
|
-
self._connection = old_connection_manager.connection
|
|
421
446
|
_logger.warning("Active reconnection failed")
|
|
422
447
|
else:
|
|
423
448
|
_logger.warning(
|
|
@@ -429,6 +454,8 @@ class NavienMqttClient(EventEmitter):
|
|
|
429
454
|
f"Error during active reconnection: {e}", exc_info=True
|
|
430
455
|
)
|
|
431
456
|
raise
|
|
457
|
+
finally:
|
|
458
|
+
self._actively_reconnecting = False
|
|
432
459
|
|
|
433
460
|
async def _deep_reconnect(self) -> None:
|
|
434
461
|
"""
|
|
@@ -448,18 +475,25 @@ class NavienMqttClient(EventEmitter):
|
|
|
448
475
|
_logger.debug("Already connected, skipping deep reconnection")
|
|
449
476
|
return
|
|
450
477
|
|
|
478
|
+
if self._actively_reconnecting:
|
|
479
|
+
_logger.debug("Active reconnection already in progress, skipping")
|
|
480
|
+
return
|
|
481
|
+
|
|
451
482
|
_logger.warning(
|
|
452
483
|
"Performing deep reconnection (full rebuild)... "
|
|
453
484
|
"This may take longer."
|
|
454
485
|
)
|
|
455
486
|
|
|
487
|
+
self._actively_reconnecting = True
|
|
456
488
|
try:
|
|
457
|
-
# 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.
|
|
458
493
|
if self._connection_manager:
|
|
459
494
|
_logger.debug("Cleaning up old connection...")
|
|
460
495
|
try:
|
|
461
|
-
|
|
462
|
-
await self._connection_manager.disconnect()
|
|
496
|
+
await self._connection_manager.close()
|
|
463
497
|
except (AwsCrtError, RuntimeError) as e:
|
|
464
498
|
# Expected: connection already dead or in bad state
|
|
465
499
|
_logger.debug(f"Error during cleanup: {e} (expected)")
|
|
@@ -532,6 +566,8 @@ class NavienMqttClient(EventEmitter):
|
|
|
532
566
|
) as e:
|
|
533
567
|
_logger.error(f"Error during deep reconnection: {e}", exc_info=True)
|
|
534
568
|
raise
|
|
569
|
+
finally:
|
|
570
|
+
self._actively_reconnecting = False
|
|
535
571
|
|
|
536
572
|
async def connect(self) -> bool:
|
|
537
573
|
"""
|
|
@@ -1294,14 +1330,20 @@ class NavienMqttClient(EventEmitter):
|
|
|
1294
1330
|
return True
|
|
1295
1331
|
|
|
1296
1332
|
# Not cached, request and wait
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
)
|
|
1333
|
+
loop = asyncio.get_running_loop()
|
|
1334
|
+
future: asyncio.Future[DeviceFeature] = loop.create_future()
|
|
1300
1335
|
|
|
1301
1336
|
def on_feature(feature: DeviceFeature) -> None:
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1337
|
+
# Called from AWS SDK thread — schedule onto the event loop
|
|
1338
|
+
# thread-safely. The done() check is inside the scheduled
|
|
1339
|
+
# callback so it runs on the event loop thread, eliminating
|
|
1340
|
+
# the race between the check and set_result.
|
|
1341
|
+
def _set_result() -> None:
|
|
1342
|
+
if not future.done():
|
|
1343
|
+
_logger.info(f"Device feature received for {redacted_mac}")
|
|
1344
|
+
future.set_result(feature)
|
|
1345
|
+
|
|
1346
|
+
loop.call_soon_threadsafe(_set_result)
|
|
1305
1347
|
|
|
1306
1348
|
_logger.info(f"Ensuring device info cached for {redacted_mac}")
|
|
1307
1349
|
await self.subscribe_device_feature(device, on_feature)
|
|
@@ -245,6 +245,44 @@ class MqttConnection:
|
|
|
245
245
|
_logger.error(f"Error during disconnect: {e}")
|
|
246
246
|
raise
|
|
247
247
|
|
|
248
|
+
async def close(self) -> None:
|
|
249
|
+
"""Unconditionally close the underlying SDK connection.
|
|
250
|
+
|
|
251
|
+
Unlike :meth:`disconnect`, this method closes the connection
|
|
252
|
+
regardless of the ``_connected`` flag. After a connection
|
|
253
|
+
interruption, ``_connected`` is ``False`` but the SDK connection
|
|
254
|
+
object is still alive and its built-in auto-reconnect can still
|
|
255
|
+
fire. Calling ``close()`` ensures the SDK connection is fully
|
|
256
|
+
torn down so its callbacks and auto-reconnect cannot interfere
|
|
257
|
+
with a replacement connection.
|
|
258
|
+
|
|
259
|
+
This method is safe to call multiple times or on already-closed
|
|
260
|
+
connections.
|
|
261
|
+
"""
|
|
262
|
+
connection = self._connection
|
|
263
|
+
self._connection = None
|
|
264
|
+
self._connected = False
|
|
265
|
+
|
|
266
|
+
if connection is None:
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
_logger.debug("Closing underlying SDK connection...")
|
|
270
|
+
try:
|
|
271
|
+
disconnect_future = cast(
|
|
272
|
+
asyncio.Future[Any], connection.disconnect()
|
|
273
|
+
)
|
|
274
|
+
await asyncio.shield(asyncio.wrap_future(disconnect_future))
|
|
275
|
+
_logger.debug("SDK connection closed")
|
|
276
|
+
except (AwsCrtError, RuntimeError) as e:
|
|
277
|
+
# Expected when connection is already dead or in bad state
|
|
278
|
+
_logger.debug(f"SDK connection close (benign): {e}")
|
|
279
|
+
except asyncio.CancelledError:
|
|
280
|
+
_logger.debug(
|
|
281
|
+
"Close operation cancelled but SDK disconnect "
|
|
282
|
+
"will complete in background"
|
|
283
|
+
)
|
|
284
|
+
raise
|
|
285
|
+
|
|
248
286
|
async def subscribe(
|
|
249
287
|
self,
|
|
250
288
|
topic: str,
|
|
@@ -95,7 +95,11 @@ class MqttMetrics:
|
|
|
95
95
|
|
|
96
96
|
def to_dict(self) -> dict[str, Any]:
|
|
97
97
|
"""Convert to dictionary for JSON serialization."""
|
|
98
|
-
|
|
98
|
+
d = asdict(self)
|
|
99
|
+
# Replace inf with None for JSON compatibility
|
|
100
|
+
if d.get("shortest_session_seconds") == float("inf"):
|
|
101
|
+
d["shortest_session_seconds"] = None
|
|
102
|
+
return d
|
|
99
103
|
|
|
100
104
|
|
|
101
105
|
class MqttDiagnosticsCollector:
|
|
@@ -213,6 +217,7 @@ class MqttDiagnosticsCollector:
|
|
|
213
217
|
|
|
214
218
|
# Update metrics
|
|
215
219
|
self._metrics.total_connection_drops += 1
|
|
220
|
+
self._metrics.total_reconnect_attempts += 1
|
|
216
221
|
if error_name:
|
|
217
222
|
self._metrics.connection_drops_by_error[error_name] = (
|
|
218
223
|
self._metrics.connection_drops_by_error.get(error_name, 0) + 1
|
|
@@ -173,6 +173,12 @@ 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
|
|
176
182
|
|
|
177
183
|
_logger.debug(
|
|
178
184
|
"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
|
)
|
|
@@ -233,11 +233,13 @@ class MqttConnectionConfig:
|
|
|
233
233
|
max_queued_commands: int = 100
|
|
234
234
|
|
|
235
235
|
def __post_init__(self) -> None:
|
|
236
|
-
"""Generate client ID if not provided."""
|
|
236
|
+
"""Generate client ID if not provided and validate settings."""
|
|
237
237
|
if not self.client_id:
|
|
238
238
|
object.__setattr__(
|
|
239
239
|
self, "client_id", f"navien-client-{uuid.uuid4().hex[:8]}"
|
|
240
240
|
)
|
|
241
|
+
if self.deep_reconnect_threshold < 1:
|
|
242
|
+
object.__setattr__(self, "deep_reconnect_threshold", 1)
|
|
241
243
|
|
|
242
244
|
|
|
243
245
|
@dataclass
|
|
@@ -175,6 +175,7 @@ src/nwp500_python.egg-info/top_level.txt
|
|
|
175
175
|
tests/conftest.py
|
|
176
176
|
tests/test_api_helpers.py
|
|
177
177
|
tests/test_auth.py
|
|
178
|
+
tests/test_bug_fixes.py
|
|
178
179
|
tests/test_cli_basic.py
|
|
179
180
|
tests/test_cli_commands.py
|
|
180
181
|
tests/test_command_decorators.py
|
|
@@ -187,6 +188,8 @@ tests/test_model_converters.py
|
|
|
187
188
|
tests/test_models.py
|
|
188
189
|
tests/test_mqtt_client_init.py
|
|
189
190
|
tests/test_mqtt_hypothesis.py
|
|
191
|
+
tests/test_mqtt_reconnection.py
|
|
192
|
+
tests/test_mqtt_reconnection_storm.py
|
|
190
193
|
tests/test_multi_device.py
|
|
191
194
|
tests/test_openei.py
|
|
192
195
|
tests/test_reservations.py
|