nwp500-python 3.1.2__tar.gz → 3.1.4__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-3.1.2 → nwp500_python-3.1.4}/.github/copilot-instructions.md +2 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/CHANGELOG.rst +36 -0
- {nwp500_python-3.1.2/src/nwp500_python.egg-info → nwp500_python-3.1.4}/PKG-INFO +1 -1
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/src/nwp500/auth.py +47 -5
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/src/nwp500/mqtt_client.py +68 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/src/nwp500/mqtt_reconnection.py +45 -7
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/src/nwp500/mqtt_subscriptions.py +18 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4/src/nwp500_python.egg-info}/PKG-INFO +1 -1
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/src/nwp500_python.egg-info/SOURCES.txt +1 -0
- nwp500_python-3.1.4/tests/test_auth.py +837 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/.coveragerc +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/.github/workflows/ci.yml +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/.github/workflows/release.yml +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/.gitignore +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/.pre-commit-config.yaml +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/.readthedocs.yml +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/AUTHORS.rst +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/CONTRIBUTING.rst +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/LICENSE.txt +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/Makefile +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/README.rst +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/RELEASE.md +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/docs/Makefile +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/docs/_static/.gitignore +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/docs/authors.rst +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/docs/changelog.rst +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/docs/conf.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/docs/configuration.rst +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/docs/development/contributing.rst +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/docs/development/history.rst +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/docs/guides/auto_recovery.rst +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/docs/guides/command_queue.rst +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/docs/guides/energy_monitoring.rst +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/docs/guides/event_system.rst +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/docs/guides/reservations.rst +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/docs/guides/time_of_use.rst +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/docs/index.rst +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/docs/installation.rst +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/docs/license.rst +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/docs/openapi.yaml +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/docs/protocol/device_features.rst +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/docs/protocol/device_status.rst +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/docs/protocol/error_codes.rst +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/docs/protocol/firmware_tracking.rst +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/docs/protocol/mqtt_protocol.rst +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/docs/protocol/rest_api.rst +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/docs/python_api/api_client.rst +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/docs/python_api/auth_client.rst +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/docs/python_api/cli.rst +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/docs/python_api/constants.rst +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/docs/python_api/events.rst +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/docs/python_api/exceptions.rst +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/docs/python_api/models.rst +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/docs/python_api/mqtt_client.rst +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/docs/quickstart.rst +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/docs/requirements.txt +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/examples/.ruff.toml +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/examples/README.md +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/examples/anti_legionella_example.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/examples/api_client_example.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/examples/auth_constructor_example.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/examples/authenticate.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/examples/auto_recovery_example.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/examples/combined_callbacks.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/examples/command_queue_demo.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/examples/device_feature_callback.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/examples/device_status_callback.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/examples/device_status_callback_debug.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/examples/energy_usage_example.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/examples/event_emitter_demo.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/examples/improved_auth_pattern.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/examples/mask.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/examples/mqtt_client_example.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/examples/periodic_device_info.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/examples/periodic_requests.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/examples/power_control_example.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/examples/reconnection_demo.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/examples/reservation_schedule_example.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/examples/set_dhw_temperature_example.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/examples/set_mode_example.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/examples/simple_auto_recovery.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/examples/simple_periodic_info.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/examples/simple_periodic_status.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/examples/test_api_client.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/examples/test_mqtt_connection.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/examples/test_mqtt_messaging.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/examples/test_periodic_minimal.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/examples/tou_openei_example.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/examples/tou_schedule_example.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/pyproject.toml +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/scripts/format.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/scripts/lint.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/scripts/setup-dev.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/setup.cfg +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/setup.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/src/nwp500/__init__.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/src/nwp500/api_client.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/src/nwp500/cli/__init__.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/src/nwp500/cli/__main__.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/src/nwp500/cli/commands.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/src/nwp500/cli/monitoring.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/src/nwp500/cli/output_formatters.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/src/nwp500/cli/token_storage.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/src/nwp500/cli.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/src/nwp500/config.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/src/nwp500/constants.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/src/nwp500/encoding.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/src/nwp500/events.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/src/nwp500/models.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/src/nwp500/mqtt_command_queue.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/src/nwp500/mqtt_connection.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/src/nwp500/mqtt_device_control.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/src/nwp500/mqtt_periodic.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/src/nwp500/mqtt_utils.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/src/nwp500/py.typed +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/src/nwp500/utils.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/src/nwp500_python.egg-info/dependency_links.txt +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/src/nwp500_python.egg-info/entry_points.txt +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/src/nwp500_python.egg-info/not-zip-safe +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/src/nwp500_python.egg-info/requires.txt +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/src/nwp500_python.egg-info/top_level.txt +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/tests/conftest.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/tests/test_api_helpers.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/tests/test_command_queue.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/tests/test_events.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/tests/test_utils.py +0 -0
- {nwp500_python-3.1.2 → nwp500_python-3.1.4}/tox.ini +0 -0
|
@@ -26,6 +26,8 @@ Always run these checks before finalizing changes to ensure your code will pass
|
|
|
26
26
|
|
|
27
27
|
This prevents "passes locally but fails in CI" issues.
|
|
28
28
|
|
|
29
|
+
**Important**: When updating CHANGELOG.rst or any file with dates, always use `date +"%Y-%m-%d"` to get the correct current date. Never hardcode or guess dates.
|
|
30
|
+
|
|
29
31
|
### After Completing a Task
|
|
30
32
|
Always run these checks after completing a task to validate your changes:
|
|
31
33
|
1. **Type checking**: `python3 -m mypy src/nwp500 --config-file pyproject.toml` - Verify no type errors were introduced
|
|
@@ -2,6 +2,42 @@
|
|
|
2
2
|
Changelog
|
|
3
3
|
=========
|
|
4
4
|
|
|
5
|
+
Version 3.1.4 (2025-10-26)
|
|
6
|
+
==========================
|
|
7
|
+
|
|
8
|
+
Fixed
|
|
9
|
+
-----
|
|
10
|
+
|
|
11
|
+
- **MQTT Reconnection**: Fixed MQTT reconnection failures due to expired AWS credentials
|
|
12
|
+
|
|
13
|
+
- Added AWS credential expiration tracking (``_aws_expires_at`` field in ``AuthTokens``)
|
|
14
|
+
- Added ``are_aws_credentials_expired`` property to check AWS credential validity
|
|
15
|
+
- Modified ``ensure_valid_token()`` to prioritize AWS credential expiration check
|
|
16
|
+
- Triggers full re-authentication (not just token refresh) when AWS credentials expire
|
|
17
|
+
- Preserves AWS credential expiration timestamps during token refresh
|
|
18
|
+
- Prevents reconnection failures when connection interrupts after AWS credentials expire but before JWT tokens expire
|
|
19
|
+
- Resolves AWS_ERROR_HTTP_WEBSOCKET_UPGRADE_FAILURE errors during reconnection attempts
|
|
20
|
+
- Improved test coverage for auth module from 31% to 60% with comprehensive test suite
|
|
21
|
+
|
|
22
|
+
Version 3.1.3 (2025-10-24)
|
|
23
|
+
==========================
|
|
24
|
+
|
|
25
|
+
Fixed
|
|
26
|
+
-----
|
|
27
|
+
|
|
28
|
+
- **MQTT Reconnection**: Improved MQTT reconnection reliability with active reconnection
|
|
29
|
+
|
|
30
|
+
- **Breaking Internal Change**: ``MqttReconnectionHandler`` now requires ``reconnect_func`` parameter (not Optional)
|
|
31
|
+
- Implemented active reconnection that always recreates MQTT connection on interruption
|
|
32
|
+
- Removed unreliable passive fallback to AWS IoT SDK automatic reconnection
|
|
33
|
+
- Added automatic connection state checking during reconnection attempts
|
|
34
|
+
- Now emits ``reconnection_failed`` event when max reconnection attempts are exhausted
|
|
35
|
+
- Improved error handling and logging during reconnection process
|
|
36
|
+
- Better recovery from WebSocket connection interruptions (AWS_ERROR_MQTT_UNEXPECTED_HANGUP)
|
|
37
|
+
- Resolves issues where connection would fail to recover after network interruptions
|
|
38
|
+
- Note: Public API unchanged - ``NavienMqttClient`` continues to work as before
|
|
39
|
+
- Compatible with existing auto-recovery examples (``auto_recovery_example.py``, ``simple_auto_recovery.py``)
|
|
40
|
+
|
|
5
41
|
Version 3.1.2 (2025-01-23)
|
|
6
42
|
==========================
|
|
7
43
|
|
|
@@ -73,6 +73,9 @@ class AuthTokens:
|
|
|
73
73
|
_expires_at: datetime = field(
|
|
74
74
|
default=datetime.now(), init=False, repr=False
|
|
75
75
|
)
|
|
76
|
+
_aws_expires_at: Optional[datetime] = field(
|
|
77
|
+
default=None, init=False, repr=False
|
|
78
|
+
)
|
|
76
79
|
|
|
77
80
|
def __post_init__(self) -> None:
|
|
78
81
|
"""Cache the expiration timestamp after initialization."""
|
|
@@ -80,6 +83,11 @@ class AuthTokens:
|
|
|
80
83
|
self._expires_at = self.issued_at + timedelta(
|
|
81
84
|
seconds=self.authentication_expires_in
|
|
82
85
|
)
|
|
86
|
+
# Calculate AWS credentials expiration if available
|
|
87
|
+
if self.authorization_expires_in:
|
|
88
|
+
self._aws_expires_at = self.issued_at + timedelta(
|
|
89
|
+
seconds=self.authorization_expires_in
|
|
90
|
+
)
|
|
83
91
|
|
|
84
92
|
@classmethod
|
|
85
93
|
def from_dict(cls, data: dict[str, Any]) -> "AuthTokens":
|
|
@@ -106,6 +114,25 @@ class AuthTokens:
|
|
|
106
114
|
# Consider expired if within 5 minutes of expiration
|
|
107
115
|
return datetime.now() >= (self._expires_at - timedelta(minutes=5))
|
|
108
116
|
|
|
117
|
+
@property
|
|
118
|
+
def are_aws_credentials_expired(self) -> bool:
|
|
119
|
+
"""Check if AWS credentials have expired.
|
|
120
|
+
|
|
121
|
+
AWS credentials have a separate expiration time from JWT tokens.
|
|
122
|
+
If AWS credentials are expired, a full re-authentication is needed
|
|
123
|
+
since the token refresh endpoint doesn't provide new AWS credentials.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
True if AWS credentials are expired, False if expiration time is
|
|
127
|
+
unknown or credentials are still valid
|
|
128
|
+
"""
|
|
129
|
+
if not self._aws_expires_at:
|
|
130
|
+
# If we don't know when AWS credentials expire, consider them valid
|
|
131
|
+
# This handles cases where authorization_expires_in wasn't provided
|
|
132
|
+
return False
|
|
133
|
+
# Consider expired if within 5 minutes of expiration
|
|
134
|
+
return datetime.now() >= (self._aws_expires_at - timedelta(minutes=5))
|
|
135
|
+
|
|
109
136
|
@property
|
|
110
137
|
def time_until_expiry(self) -> timedelta:
|
|
111
138
|
"""Get time remaining until token expiration.
|
|
@@ -423,6 +450,8 @@ class NavienAuthClient:
|
|
|
423
450
|
new_tokens.authorization_expires_in = (
|
|
424
451
|
old_tokens.authorization_expires_in
|
|
425
452
|
)
|
|
453
|
+
# Also preserve the AWS expiration timestamp
|
|
454
|
+
new_tokens._aws_expires_at = old_tokens._aws_expires_at
|
|
426
455
|
|
|
427
456
|
# Update stored auth response if we have one
|
|
428
457
|
if self._auth_response:
|
|
@@ -446,23 +475,36 @@ class NavienAuthClient:
|
|
|
446
475
|
"""
|
|
447
476
|
Ensure we have a valid access token, refreshing if necessary.
|
|
448
477
|
|
|
478
|
+
This method checks both JWT token and AWS credentials expiration.
|
|
479
|
+
If AWS credentials are expired, it triggers a full re-authentication
|
|
480
|
+
since the token refresh endpoint doesn't provide new AWS credentials.
|
|
481
|
+
|
|
449
482
|
Returns:
|
|
450
483
|
Valid AuthTokens or None if not authenticated
|
|
451
484
|
|
|
452
485
|
Raises:
|
|
453
486
|
TokenRefreshError: If token refresh fails
|
|
487
|
+
AuthenticationError: If re-authentication fails
|
|
454
488
|
"""
|
|
455
489
|
if not self._auth_response:
|
|
456
490
|
_logger.warning("No authentication response available")
|
|
457
491
|
return None
|
|
458
492
|
|
|
459
|
-
|
|
493
|
+
tokens = self._auth_response.tokens
|
|
494
|
+
|
|
495
|
+
# Check if AWS credentials have expired
|
|
496
|
+
if tokens.are_aws_credentials_expired:
|
|
497
|
+
_logger.info("AWS credentials expired, re-authenticating...")
|
|
498
|
+
# Re-authenticate to get fresh AWS credentials
|
|
499
|
+
await self.sign_in(self._user_id, self._password)
|
|
500
|
+
return self._auth_response.tokens if self._auth_response else None
|
|
501
|
+
|
|
502
|
+
# Check if JWT token has expired
|
|
503
|
+
if tokens.is_expired:
|
|
460
504
|
_logger.info("Token expired, refreshing...")
|
|
461
|
-
return await self.refresh_token(
|
|
462
|
-
self._auth_response.tokens.refresh_token
|
|
463
|
-
)
|
|
505
|
+
return await self.refresh_token(tokens.refresh_token)
|
|
464
506
|
|
|
465
|
-
return
|
|
507
|
+
return tokens
|
|
466
508
|
|
|
467
509
|
@property
|
|
468
510
|
def is_authenticated(self) -> bool:
|
|
@@ -279,6 +279,72 @@ class NavienMqttClient(EventEmitter):
|
|
|
279
279
|
self._connection_manager.publish, lambda: self._connected
|
|
280
280
|
)
|
|
281
281
|
|
|
282
|
+
async def _active_reconnect(self) -> None:
|
|
283
|
+
"""
|
|
284
|
+
Actively trigger a reconnection attempt.
|
|
285
|
+
|
|
286
|
+
This method is called by the reconnection handler to actively
|
|
287
|
+
reconnect instead of passively waiting for AWS IoT SDK.
|
|
288
|
+
|
|
289
|
+
Note: This creates a new connection while preserving subscriptions
|
|
290
|
+
and configuration.
|
|
291
|
+
"""
|
|
292
|
+
if self._connected:
|
|
293
|
+
_logger.debug("Already connected, skipping reconnection")
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
_logger.info("Attempting active reconnection...")
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
# Ensure tokens are still valid
|
|
300
|
+
await self._auth_client.ensure_valid_token()
|
|
301
|
+
|
|
302
|
+
# If we have a connection manager, try to reconnect using it
|
|
303
|
+
if self._connection_manager:
|
|
304
|
+
# The connection might be in a bad state, so we need to
|
|
305
|
+
# recreate the underlying connection
|
|
306
|
+
_logger.debug("Recreating MQTT connection...")
|
|
307
|
+
|
|
308
|
+
# Create a new connection manager with same config
|
|
309
|
+
old_connection_manager = self._connection_manager
|
|
310
|
+
self._connection_manager = MqttConnection(
|
|
311
|
+
config=self.config,
|
|
312
|
+
auth_client=self._auth_client,
|
|
313
|
+
on_connection_interrupted=self._on_connection_interrupted_internal,
|
|
314
|
+
on_connection_resumed=self._on_connection_resumed_internal,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
# Try to connect
|
|
318
|
+
success = await self._connection_manager.connect()
|
|
319
|
+
|
|
320
|
+
if success:
|
|
321
|
+
# Update connection references
|
|
322
|
+
self._connection = self._connection_manager.connection
|
|
323
|
+
self._connected = True
|
|
324
|
+
|
|
325
|
+
# Update subscription manager with new connection
|
|
326
|
+
if self._subscription_manager and self._connection:
|
|
327
|
+
self._subscription_manager.update_connection(
|
|
328
|
+
self._connection
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
_logger.info("Active reconnection successful")
|
|
332
|
+
else:
|
|
333
|
+
# Restore old connection manager and connection reference
|
|
334
|
+
self._connection_manager = old_connection_manager
|
|
335
|
+
self._connection = old_connection_manager.connection
|
|
336
|
+
_logger.warning("Active reconnection failed")
|
|
337
|
+
else:
|
|
338
|
+
_logger.warning(
|
|
339
|
+
"No connection manager available for reconnection"
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
except Exception as e:
|
|
343
|
+
_logger.error(
|
|
344
|
+
f"Error during active reconnection: {e}", exc_info=True
|
|
345
|
+
)
|
|
346
|
+
raise
|
|
347
|
+
|
|
282
348
|
async def connect(self) -> bool:
|
|
283
349
|
"""
|
|
284
350
|
Establish connection to AWS IoT Core.
|
|
@@ -327,6 +393,8 @@ class NavienMqttClient(EventEmitter):
|
|
|
327
393
|
config=self.config,
|
|
328
394
|
is_connected_func=lambda: self._connected,
|
|
329
395
|
schedule_coroutine_func=self._schedule_coroutine,
|
|
396
|
+
reconnect_func=self._active_reconnect,
|
|
397
|
+
emit_event_func=self.emit,
|
|
330
398
|
)
|
|
331
399
|
self._reconnection_handler.enable()
|
|
332
400
|
|
|
@@ -8,6 +8,7 @@ the MQTT connection is interrupted.
|
|
|
8
8
|
import asyncio
|
|
9
9
|
import contextlib
|
|
10
10
|
import logging
|
|
11
|
+
from collections.abc import Awaitable
|
|
11
12
|
from typing import TYPE_CHECKING, Any, Callable, Optional
|
|
12
13
|
|
|
13
14
|
if TYPE_CHECKING:
|
|
@@ -33,6 +34,8 @@ class MqttReconnectionHandler:
|
|
|
33
34
|
config: "MqttConnectionConfig",
|
|
34
35
|
is_connected_func: Callable[[], bool],
|
|
35
36
|
schedule_coroutine_func: Callable[[Any], None],
|
|
37
|
+
reconnect_func: Callable[[], Awaitable[None]],
|
|
38
|
+
emit_event_func: Optional[Callable[..., Awaitable[Any]]] = None,
|
|
36
39
|
):
|
|
37
40
|
"""
|
|
38
41
|
Initialize reconnection handler.
|
|
@@ -42,10 +45,15 @@ class MqttReconnectionHandler:
|
|
|
42
45
|
is_connected_func: Function to check if currently connected
|
|
43
46
|
schedule_coroutine_func: Function to schedule coroutines from any
|
|
44
47
|
thread
|
|
48
|
+
reconnect_func: Async function to trigger active reconnection
|
|
49
|
+
emit_event_func: Optional async function to emit events
|
|
50
|
+
(e.g., EventEmitter.emit)
|
|
45
51
|
"""
|
|
46
52
|
self.config = config
|
|
47
53
|
self._is_connected_func = is_connected_func
|
|
48
54
|
self._schedule_coroutine = schedule_coroutine_func
|
|
55
|
+
self._reconnect_func = reconnect_func
|
|
56
|
+
self._emit_event = emit_event_func
|
|
49
57
|
|
|
50
58
|
self._reconnect_attempts = 0
|
|
51
59
|
self._reconnect_task: Optional[asyncio.Task[None]] = None
|
|
@@ -156,24 +164,54 @@ class MqttReconnectionHandler:
|
|
|
156
164
|
try:
|
|
157
165
|
await asyncio.sleep(delay)
|
|
158
166
|
|
|
159
|
-
#
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
167
|
+
# Check if we're already connected (AWS SDK auto-reconnected)
|
|
168
|
+
if self._is_connected_func():
|
|
169
|
+
_logger.info(
|
|
170
|
+
"AWS IoT SDK automatically reconnected during delay"
|
|
171
|
+
)
|
|
172
|
+
break
|
|
173
|
+
|
|
174
|
+
# Trigger active reconnection
|
|
175
|
+
_logger.info("Triggering active reconnection...")
|
|
176
|
+
try:
|
|
177
|
+
await self._reconnect_func()
|
|
178
|
+
if self._is_connected_func():
|
|
179
|
+
_logger.info("Successfully reconnected")
|
|
180
|
+
break
|
|
181
|
+
except Exception as e:
|
|
182
|
+
_logger.warning(
|
|
183
|
+
f"Active reconnection failed: {e}. "
|
|
184
|
+
"Will retry if attempts remain."
|
|
185
|
+
)
|
|
164
186
|
|
|
165
187
|
except asyncio.CancelledError:
|
|
166
188
|
_logger.info("Reconnection task cancelled")
|
|
167
189
|
break
|
|
168
190
|
except Exception as e:
|
|
169
|
-
_logger.error(
|
|
191
|
+
_logger.error(
|
|
192
|
+
f"Error during reconnection attempt: {e}", exc_info=True
|
|
193
|
+
)
|
|
170
194
|
|
|
171
|
-
|
|
195
|
+
# Check final state
|
|
196
|
+
if (
|
|
197
|
+
self._reconnect_attempts >= self.config.max_reconnect_attempts
|
|
198
|
+
and not self._is_connected_func()
|
|
199
|
+
):
|
|
172
200
|
_logger.error(
|
|
173
201
|
f"Failed to reconnect after "
|
|
174
202
|
f"{self.config.max_reconnect_attempts} attempts. "
|
|
175
203
|
"Manual reconnection required."
|
|
176
204
|
)
|
|
205
|
+
# Emit reconnection_failed event if event emitter is available
|
|
206
|
+
if self._emit_event:
|
|
207
|
+
try:
|
|
208
|
+
await self._emit_event(
|
|
209
|
+
"reconnection_failed", self._reconnect_attempts
|
|
210
|
+
)
|
|
211
|
+
except Exception as e:
|
|
212
|
+
_logger.error(
|
|
213
|
+
f"Error emitting reconnection_failed event: {e}"
|
|
214
|
+
)
|
|
177
215
|
|
|
178
216
|
async def cancel(self) -> None:
|
|
179
217
|
"""Cancel any pending reconnection task."""
|
|
@@ -72,6 +72,24 @@ class MqttSubscriptionManager:
|
|
|
72
72
|
"""Get current subscriptions."""
|
|
73
73
|
return self._subscriptions.copy()
|
|
74
74
|
|
|
75
|
+
def update_connection(self, connection: Any) -> None:
|
|
76
|
+
"""
|
|
77
|
+
Update the MQTT connection reference.
|
|
78
|
+
|
|
79
|
+
This is used when the connection is recreated (e.g., after reconnection)
|
|
80
|
+
to update the internal reference while preserving subscriptions.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
connection: New MQTT connection object
|
|
84
|
+
|
|
85
|
+
Note:
|
|
86
|
+
This does not re-establish subscriptions. Call the appropriate
|
|
87
|
+
subscribe methods to re-register subscriptions with the new
|
|
88
|
+
connection if needed.
|
|
89
|
+
"""
|
|
90
|
+
self._connection = connection
|
|
91
|
+
_logger.debug("Updated subscription manager connection reference")
|
|
92
|
+
|
|
75
93
|
def _on_message_received(
|
|
76
94
|
self, topic: str, payload: bytes, **kwargs: Any
|
|
77
95
|
) -> None:
|