nwp500-python 8.0.0__tar.gz → 8.1.0__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.0}/CHANGELOG.rst +64 -2
- {nwp500_python-8.0.0/src/nwp500_python.egg-info → nwp500_python-8.1.0}/PKG-INFO +1 -1
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/scripts/bump_version.py +77 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/device_info_cache.py +9 -6
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/encoding.py +30 -8
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/events.py +1 -1
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/mqtt/client.py +23 -15
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/mqtt/connection.py +38 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/mqtt/diagnostics.py +6 -1
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/mqtt/periodic.py +6 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/mqtt/utils.py +3 -1
- {nwp500_python-8.0.0 → nwp500_python-8.1.0/src/nwp500_python.egg-info}/PKG-INFO +1 -1
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500_python.egg-info/SOURCES.txt +2 -0
- nwp500_python-8.1.0/tests/test_bug_fixes.py +198 -0
- nwp500_python-8.1.0/tests/test_mqtt_reconnection.py +130 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/.coveragerc +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/.github/RESOLVING_PR_COMMENTS.md +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/.github/copilot-instructions.md +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/.github/workflows/ci.yml +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/.github/workflows/release.yml +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/.gitignore +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/.pre-commit-config.yaml +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/.readthedocs.yml +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/AUTHORS.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/CONTRIBUTING.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/LICENSE.txt +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/Makefile +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/README.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/RELEASE.md +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/Makefile +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/_static/.gitignore +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/conf.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/explanation/advanced-features.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/explanation/architecture.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/explanation/index.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/how-to/authenticate.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/how-to/auto-recovery.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/how-to/diagnose-mqtt.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/how-to/home-assistant.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/how-to/index.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/how-to/maintenance.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/how-to/manage-units.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/how-to/monitor-status.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/how-to/optimize-tou.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/how-to/queue-commands.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/how-to/schedule-operation.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/how-to/track-energy.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/index.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/openapi.yaml +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/project/authors.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/project/changelog.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/project/contributing.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/project/history.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/project/license.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/reference/configuration.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/reference/enumerations.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/reference/index.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/reference/installation.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/reference/protocol/data_conversions.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/reference/protocol/device_features.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/reference/protocol/device_status.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/reference/protocol/error_codes.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/reference/protocol/mqtt_protocol.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/reference/protocol/quick_reference.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/reference/protocol/rest_api.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/reference/python_api/api_client.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/reference/python_api/auth_client.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/reference/python_api/cli.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/reference/python_api/events.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/reference/python_api/exceptions.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/reference/python_api/models.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/reference/python_api/mqtt_client.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/requirements.txt +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/tutorials/getting-started.rst +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/.ruff.toml +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/README.md +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/advanced/air_filter_reset.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/advanced/anti_legionella.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/advanced/auto_recovery.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/advanced/combined_callbacks.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/advanced/demand_response.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/advanced/device_capabilities.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/advanced/device_status_debug.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/advanced/energy_analytics.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/advanced/error_code_demo.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/advanced/firmware_payload_capture.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/advanced/mqtt_diagnostics.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/advanced/power_control.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/advanced/recirculation_control.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/advanced/reconnection_demo.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/advanced/reservation_schedule.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/advanced/simple_auto_recovery.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/advanced/token_restoration.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/advanced/tou_openei.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/advanced/tou_schedule.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/advanced/water_reservation.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/beginner/01_authentication.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/beginner/02_list_devices.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/beginner/03_get_status.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/beginner/04_set_temperature.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/intermediate/advanced_auth_patterns.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/intermediate/command_queue.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/intermediate/device_status_callback.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/intermediate/error_handling.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/intermediate/event_driven_control.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/intermediate/improved_auth.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/intermediate/legacy_auth_constructor.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/intermediate/mqtt_realtime_monitoring.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/intermediate/periodic_requests.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/intermediate/set_mode.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/intermediate/vacation_mode.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/mask.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/testing/periodic_device_info.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/testing/simple_periodic_info.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/testing/test_api_client.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/testing/test_mqtt_connection.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/testing/test_mqtt_messaging.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/testing/test_periodic_minimal.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/pyproject.toml +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/scripts/README.md +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/scripts/diagnose_mqtt_connection.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/scripts/extract_changelog.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/scripts/format.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/scripts/lint.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/scripts/setup-dev.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/scripts/validate_version.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/setup.cfg +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/setup.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/__init__.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/_base.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/api_client.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/auth.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/cli/__init__.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/cli/__main__.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/cli/commands.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/cli/handlers.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/cli/monitoring.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/cli/output_formatters.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/cli/rich_output.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/cli/token_storage.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/command_decorators.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/config.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/converters.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/device_capabilities.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/enums.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/exceptions.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/factory.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/field_factory.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/models/__init__.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/models/_converters.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/models/device.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/models/energy.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/models/feature.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/models/mqtt_models.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/models/schedule.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/models/status.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/models/tou.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/mqtt/__init__.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/mqtt/command_queue.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/mqtt/control.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/mqtt/reconnection.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/mqtt/state_tracker.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/mqtt/subscriptions.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/mqtt_events.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/openei.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/py.typed +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/reservations.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/temperature.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/topic_builder.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/unit_system.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/utils.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500_python.egg-info/dependency_links.txt +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500_python.egg-info/entry_points.txt +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500_python.egg-info/not-zip-safe +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500_python.egg-info/requires.txt +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500_python.egg-info/top_level.txt +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/conftest.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_api_helpers.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_auth.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_cli_basic.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_cli_commands.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_command_decorators.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_command_queue.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_device_capabilities.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_device_info_cache.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_events.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_exceptions.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_model_converters.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_models.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_mqtt_client_init.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_mqtt_hypothesis.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_multi_device.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_openei.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_reservations.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_temperature_converters.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_tou_api.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_unit_switching.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_utils.py +0 -0
- {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tox.ini +0 -0
|
@@ -2,8 +2,70 @@
|
|
|
2
2
|
Changelog
|
|
3
3
|
=========
|
|
4
4
|
|
|
5
|
-
Unreleased
|
|
6
|
-
|
|
5
|
+
Unreleased
|
|
6
|
+
==========
|
|
7
|
+
|
|
8
|
+
Version 8.1.0 (2026-05-16)
|
|
9
|
+
==========================
|
|
10
|
+
|
|
11
|
+
Bug Fixes
|
|
12
|
+
---------
|
|
13
|
+
- **Fix MQTT connection flapping after reconnect**: When ``_active_reconnect()``
|
|
14
|
+
created a new ``MqttConnection``, the old connection was never closed. The old
|
|
15
|
+
SDK connection's built-in auto-reconnect would eventually succeed, creating two
|
|
16
|
+
active connections sharing the same client ID. Because AWS IoT allows only one
|
|
17
|
+
connection per client ID, the broker would kick one off, triggering
|
|
18
|
+
``on_connection_interrupted`` and starting yet another reconnection — an
|
|
19
|
+
infinite connect/disconnect loop. Fixed by adding ``MqttConnection.close()``
|
|
20
|
+
(unconditional teardown regardless of ``_connected`` state) and calling it
|
|
21
|
+
before creating the replacement connection in both ``_active_reconnect()`` and
|
|
22
|
+
``_deep_reconnect()``.
|
|
23
|
+
|
|
24
|
+
- **Thread-safety race in ``ensure_device_info_cached``**: The ``future.done()``
|
|
25
|
+
check and ``future.set_result()`` were performed in the AWS SDK callback thread
|
|
26
|
+
without synchronisation, creating a race against the asyncio event loop thread.
|
|
27
|
+
Moved both operations inside a ``call_soon_threadsafe`` callback so they execute
|
|
28
|
+
atomically on the event loop thread.
|
|
29
|
+
|
|
30
|
+
- **ZeroDivisionError when ``deep_reconnect_threshold`` is 0**: Config validation
|
|
31
|
+
now clamps ``deep_reconnect_threshold`` to a minimum of 1, preventing a
|
|
32
|
+
``ZeroDivisionError`` in the exponential-backoff reconnection logic.
|
|
33
|
+
|
|
34
|
+
- **Reconnect counter never incremented**: ``total_reconnect_attempts`` in
|
|
35
|
+
diagnostics was not incremented on connection drops, so it always reported 0
|
|
36
|
+
despite active reconnections. Counter is now incremented on each
|
|
37
|
+
``on_connection_interrupted`` event.
|
|
38
|
+
|
|
39
|
+
- **``shortest_session_seconds`` not JSON-serialisable**: The diagnostics
|
|
40
|
+
``to_dict()`` method used ``float('inf')`` as the initial value for
|
|
41
|
+
``shortest_session_seconds``, which is not valid JSON. Changed to ``None``
|
|
42
|
+
so serialisation succeeds when no session has completed yet.
|
|
43
|
+
|
|
44
|
+
- **``wait_for()`` future not bound to running loop**: ``wait_for()`` created a
|
|
45
|
+
bare ``asyncio.Future()`` rather than
|
|
46
|
+
``asyncio.get_running_loop().create_future()``, which could bind the future to
|
|
47
|
+
a different loop in multi-loop test setups.
|
|
48
|
+
|
|
49
|
+
- **Reservation temperature validation was US-only**: ``build_reservation_entry``
|
|
50
|
+
validated set-point temperatures against hardcoded Fahrenheit bounds (95–150 °F)
|
|
51
|
+
regardless of the active unit system. Validation now uses the current unit system
|
|
52
|
+
context: 35–65 °C in metric mode, 95–150 °F in US mode. Celsius users previously
|
|
53
|
+
received spurious ``ValueError`` rejections for valid temperatures.
|
|
54
|
+
|
|
55
|
+
- **Malformed reservation data silently dropped**: ``build_reservation_entry`` now
|
|
56
|
+
logs a warning when reservation hex data contains unexpected trailing bytes
|
|
57
|
+
instead of silently dropping partial entries.
|
|
58
|
+
|
|
59
|
+
- **Unknown ``PeriodicRequestType`` silently ignored**: The periodic-request handler
|
|
60
|
+
now logs an error and breaks when it encounters an unknown request type instead of
|
|
61
|
+
doing nothing.
|
|
62
|
+
|
|
63
|
+
- **Memory leak in device info cache**: ``get_all_cached()`` only filtered expired
|
|
64
|
+
entries from its return value but left them in the cache dictionary. Expired
|
|
65
|
+
entries are now evicted during ``get_all_cached()`` to prevent unbounded growth.
|
|
66
|
+
|
|
67
|
+
Version 8.0.0 (2026-05-13)
|
|
68
|
+
===========================
|
|
7
69
|
|
|
8
70
|
Bug Fixes
|
|
9
71
|
---------
|
|
@@ -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:
|
|
@@ -371,7 +371,10 @@ class NavienMqttClient(EventEmitter):
|
|
|
371
371
|
reconnect instead of passively waiting for AWS IoT SDK.
|
|
372
372
|
|
|
373
373
|
Note: This creates a new connection while preserving subscriptions
|
|
374
|
-
and configuration.
|
|
374
|
+
and configuration. The old connection is closed first to prevent
|
|
375
|
+
its SDK auto-reconnect from creating a competing connection with
|
|
376
|
+
the same client ID (which causes the broker to kick one off,
|
|
377
|
+
leading to an infinite connect/disconnect loop).
|
|
375
378
|
"""
|
|
376
379
|
if self._connected:
|
|
377
380
|
_logger.debug("Already connected, skipping reconnection")
|
|
@@ -385,12 +388,15 @@ class NavienMqttClient(EventEmitter):
|
|
|
385
388
|
|
|
386
389
|
# If we have a connection manager, try to reconnect using it
|
|
387
390
|
if self._connection_manager:
|
|
388
|
-
#
|
|
389
|
-
#
|
|
391
|
+
# Close old connection to stop SDK auto-reconnect and
|
|
392
|
+
# prevent two connections with the same client ID.
|
|
390
393
|
_logger.debug("Recreating MQTT connection...")
|
|
394
|
+
try:
|
|
395
|
+
await self._connection_manager.close()
|
|
396
|
+
except (AwsCrtError, RuntimeError) as e:
|
|
397
|
+
_logger.debug(f"Old connection cleanup (benign): {e}")
|
|
391
398
|
|
|
392
399
|
# Create a new connection manager with same config
|
|
393
|
-
old_connection_manager = self._connection_manager
|
|
394
400
|
self._connection_manager = MqttConnection(
|
|
395
401
|
config=self.config,
|
|
396
402
|
auth_client=self._auth_client,
|
|
@@ -415,9 +421,6 @@ class NavienMqttClient(EventEmitter):
|
|
|
415
421
|
|
|
416
422
|
_logger.info("Active reconnection successful")
|
|
417
423
|
else:
|
|
418
|
-
# Restore old connection manager and connection reference
|
|
419
|
-
self._connection_manager = old_connection_manager
|
|
420
|
-
self._connection = old_connection_manager.connection
|
|
421
424
|
_logger.warning("Active reconnection failed")
|
|
422
425
|
else:
|
|
423
426
|
_logger.warning(
|
|
@@ -458,8 +461,7 @@ class NavienMqttClient(EventEmitter):
|
|
|
458
461
|
if self._connection_manager:
|
|
459
462
|
_logger.debug("Cleaning up old connection...")
|
|
460
463
|
try:
|
|
461
|
-
|
|
462
|
-
await self._connection_manager.disconnect()
|
|
464
|
+
await self._connection_manager.close()
|
|
463
465
|
except (AwsCrtError, RuntimeError) as e:
|
|
464
466
|
# Expected: connection already dead or in bad state
|
|
465
467
|
_logger.debug(f"Error during cleanup: {e} (expected)")
|
|
@@ -1294,14 +1296,20 @@ class NavienMqttClient(EventEmitter):
|
|
|
1294
1296
|
return True
|
|
1295
1297
|
|
|
1296
1298
|
# Not cached, request and wait
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
)
|
|
1299
|
+
loop = asyncio.get_running_loop()
|
|
1300
|
+
future: asyncio.Future[DeviceFeature] = loop.create_future()
|
|
1300
1301
|
|
|
1301
1302
|
def on_feature(feature: DeviceFeature) -> None:
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1303
|
+
# Called from AWS SDK thread — schedule onto the event loop
|
|
1304
|
+
# thread-safely. The done() check is inside the scheduled
|
|
1305
|
+
# callback so it runs on the event loop thread, eliminating
|
|
1306
|
+
# the race between the check and set_result.
|
|
1307
|
+
def _set_result() -> None:
|
|
1308
|
+
if not future.done():
|
|
1309
|
+
_logger.info(f"Device feature received for {redacted_mac}")
|
|
1310
|
+
future.set_result(feature)
|
|
1311
|
+
|
|
1312
|
+
loop.call_soon_threadsafe(_set_result)
|
|
1305
1313
|
|
|
1306
1314
|
_logger.info(f"Ensuring device info cached for {redacted_mac}")
|
|
1307
1315
|
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",
|
|
@@ -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,7 @@ 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
|
|
190
192
|
tests/test_multi_device.py
|
|
191
193
|
tests/test_openei.py
|
|
192
194
|
tests/test_reservations.py
|