nwp500-python 3.1.1__tar.gz → 3.1.3__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.1 → nwp500_python-3.1.3}/.github/copilot-instructions.md +2 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/CHANGELOG.rst +36 -0
- {nwp500_python-3.1.1/src/nwp500_python.egg-info → nwp500_python-3.1.3}/PKG-INFO +1 -1
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/api_client.py +43 -1
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/auth.py +24 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/cli/__main__.py +4 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/mqtt_client.py +68 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/mqtt_reconnection.py +45 -7
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/mqtt_subscriptions.py +18 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3/src/nwp500_python.egg-info}/PKG-INFO +1 -1
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/.coveragerc +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/.github/workflows/ci.yml +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/.github/workflows/release.yml +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/.gitignore +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/.pre-commit-config.yaml +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/.readthedocs.yml +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/AUTHORS.rst +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/CONTRIBUTING.rst +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/LICENSE.txt +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/Makefile +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/README.rst +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/RELEASE.md +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/Makefile +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/_static/.gitignore +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/authors.rst +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/changelog.rst +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/conf.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/configuration.rst +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/development/contributing.rst +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/development/history.rst +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/guides/auto_recovery.rst +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/guides/command_queue.rst +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/guides/energy_monitoring.rst +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/guides/event_system.rst +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/guides/reservations.rst +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/guides/time_of_use.rst +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/index.rst +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/installation.rst +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/license.rst +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/openapi.yaml +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/protocol/device_features.rst +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/protocol/device_status.rst +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/protocol/error_codes.rst +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/protocol/firmware_tracking.rst +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/protocol/mqtt_protocol.rst +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/protocol/rest_api.rst +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/python_api/api_client.rst +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/python_api/auth_client.rst +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/python_api/cli.rst +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/python_api/constants.rst +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/python_api/events.rst +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/python_api/exceptions.rst +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/python_api/models.rst +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/python_api/mqtt_client.rst +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/quickstart.rst +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/requirements.txt +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/.ruff.toml +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/README.md +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/anti_legionella_example.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/api_client_example.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/auth_constructor_example.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/authenticate.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/auto_recovery_example.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/combined_callbacks.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/command_queue_demo.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/device_feature_callback.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/device_status_callback.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/device_status_callback_debug.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/energy_usage_example.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/event_emitter_demo.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/improved_auth_pattern.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/mask.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/mqtt_client_example.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/periodic_device_info.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/periodic_requests.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/power_control_example.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/reconnection_demo.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/reservation_schedule_example.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/set_dhw_temperature_example.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/set_mode_example.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/simple_auto_recovery.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/simple_periodic_info.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/simple_periodic_status.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/test_api_client.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/test_mqtt_connection.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/test_mqtt_messaging.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/test_periodic_minimal.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/tou_openei_example.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/tou_schedule_example.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/pyproject.toml +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/scripts/format.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/scripts/lint.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/scripts/setup-dev.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/setup.cfg +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/setup.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/__init__.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/cli/__init__.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/cli/commands.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/cli/monitoring.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/cli/output_formatters.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/cli/token_storage.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/cli.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/config.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/constants.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/encoding.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/events.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/models.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/mqtt_command_queue.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/mqtt_connection.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/mqtt_device_control.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/mqtt_periodic.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/mqtt_utils.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/py.typed +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/utils.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500_python.egg-info/SOURCES.txt +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500_python.egg-info/dependency_links.txt +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500_python.egg-info/entry_points.txt +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500_python.egg-info/not-zip-safe +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500_python.egg-info/requires.txt +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500_python.egg-info/top_level.txt +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/tests/conftest.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/tests/test_api_helpers.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/tests/test_command_queue.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/tests/test_events.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/tests/test_utils.py +0 -0
- {nwp500_python-3.1.1 → nwp500_python-3.1.3}/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.3 (2025-10-24)
|
|
6
|
+
==========================
|
|
7
|
+
|
|
8
|
+
Fixed
|
|
9
|
+
-----
|
|
10
|
+
|
|
11
|
+
- **MQTT Reconnection**: Improved MQTT reconnection reliability with active reconnection
|
|
12
|
+
|
|
13
|
+
- **Breaking Internal Change**: ``MqttReconnectionHandler`` now requires ``reconnect_func`` parameter (not Optional)
|
|
14
|
+
- Implemented active reconnection that always recreates MQTT connection on interruption
|
|
15
|
+
- Removed unreliable passive fallback to AWS IoT SDK automatic reconnection
|
|
16
|
+
- Added automatic connection state checking during reconnection attempts
|
|
17
|
+
- Now emits ``reconnection_failed`` event when max reconnection attempts are exhausted
|
|
18
|
+
- Improved error handling and logging during reconnection process
|
|
19
|
+
- Better recovery from WebSocket connection interruptions (AWS_ERROR_MQTT_UNEXPECTED_HANGUP)
|
|
20
|
+
- Resolves issues where connection would fail to recover after network interruptions
|
|
21
|
+
- Note: Public API unchanged - ``NavienMqttClient`` continues to work as before
|
|
22
|
+
- Compatible with existing auto-recovery examples (``auto_recovery_example.py``, ``simple_auto_recovery.py``)
|
|
23
|
+
|
|
24
|
+
Version 3.1.2 (2025-01-23)
|
|
25
|
+
==========================
|
|
26
|
+
|
|
27
|
+
Fixed
|
|
28
|
+
-----
|
|
29
|
+
|
|
30
|
+
- **Authentication**: Fixed 401 authentication errors with automatic token refresh
|
|
31
|
+
|
|
32
|
+
- Add automatic token refresh on 401 Unauthorized responses in API client
|
|
33
|
+
- Preserve AWS credentials when refreshing tokens (required for MQTT)
|
|
34
|
+
- Save refreshed tokens to cache after successful API calls
|
|
35
|
+
- Add retry logic to prevent infinite retry loops
|
|
36
|
+
- Validate refresh_token exists before attempting refresh
|
|
37
|
+
- Use specific exception types (TokenRefreshError, AuthenticationError) in error handling
|
|
38
|
+
- Prevents masking unexpected errors during token refresh
|
|
39
|
+
- Resolves 'API request failed: 401' error when using cached tokens
|
|
40
|
+
|
|
5
41
|
Version 3.1.1 (2025-01-22)
|
|
6
42
|
==========================
|
|
7
43
|
|
|
@@ -9,7 +9,11 @@ from typing import Any, Optional
|
|
|
9
9
|
|
|
10
10
|
import aiohttp
|
|
11
11
|
|
|
12
|
-
from .auth import
|
|
12
|
+
from .auth import (
|
|
13
|
+
AuthenticationError,
|
|
14
|
+
NavienAuthClient,
|
|
15
|
+
TokenRefreshError,
|
|
16
|
+
)
|
|
13
17
|
from .config import API_BASE_URL
|
|
14
18
|
from .models import Device, FirmwareInfo, TOUInfo
|
|
15
19
|
|
|
@@ -114,6 +118,7 @@ class NavienAPIClient:
|
|
|
114
118
|
endpoint: str,
|
|
115
119
|
json_data: Optional[dict[str, Any]] = None,
|
|
116
120
|
params: Optional[dict[str, Any]] = None,
|
|
121
|
+
retry_on_auth_failure: bool = True,
|
|
117
122
|
) -> dict[str, Any]:
|
|
118
123
|
"""
|
|
119
124
|
Make an authenticated API request.
|
|
@@ -123,6 +128,7 @@ class NavienAPIClient:
|
|
|
123
128
|
endpoint: API endpoint path
|
|
124
129
|
json_data: JSON body data
|
|
125
130
|
params: Query parameters
|
|
131
|
+
retry_on_auth_failure: Whether to retry once on 401 errors
|
|
126
132
|
|
|
127
133
|
Returns:
|
|
128
134
|
Response data dictionary
|
|
@@ -158,6 +164,42 @@ class NavienAPIClient:
|
|
|
158
164
|
msg = response_data.get("msg", "")
|
|
159
165
|
|
|
160
166
|
if code != 200 or not response.ok:
|
|
167
|
+
# If we get a 401 and haven't retried yet, try refreshing
|
|
168
|
+
# token
|
|
169
|
+
if code == 401 and retry_on_auth_failure:
|
|
170
|
+
_logger.warning(
|
|
171
|
+
"Received 401 Unauthorized. "
|
|
172
|
+
"Attempting to refresh token..."
|
|
173
|
+
)
|
|
174
|
+
try:
|
|
175
|
+
# Try to refresh the token
|
|
176
|
+
tokens = self._auth_client.current_tokens
|
|
177
|
+
if tokens and tokens.refresh_token:
|
|
178
|
+
await self._auth_client.refresh_token(
|
|
179
|
+
tokens.refresh_token
|
|
180
|
+
)
|
|
181
|
+
# Retry the request once with new token
|
|
182
|
+
return await self._make_request(
|
|
183
|
+
method,
|
|
184
|
+
endpoint,
|
|
185
|
+
json_data,
|
|
186
|
+
params,
|
|
187
|
+
retry_on_auth_failure=False,
|
|
188
|
+
)
|
|
189
|
+
else:
|
|
190
|
+
_logger.error(
|
|
191
|
+
"Cannot refresh token: "
|
|
192
|
+
"refresh_token not available"
|
|
193
|
+
)
|
|
194
|
+
except (
|
|
195
|
+
TokenRefreshError,
|
|
196
|
+
AuthenticationError,
|
|
197
|
+
) as refresh_error:
|
|
198
|
+
_logger.error(
|
|
199
|
+
f"Token refresh failed: {refresh_error}"
|
|
200
|
+
)
|
|
201
|
+
# Fall through to raise original error
|
|
202
|
+
|
|
161
203
|
_logger.error(f"API error: {code} - {msg}")
|
|
162
204
|
raise APIError(
|
|
163
205
|
f"API request failed: {msg}",
|
|
@@ -400,6 +400,30 @@ class NavienAuthClient:
|
|
|
400
400
|
data = response_data.get("data", {})
|
|
401
401
|
new_tokens = AuthTokens.from_dict(data)
|
|
402
402
|
|
|
403
|
+
# Preserve AWS credentials from old tokens if not in refresh
|
|
404
|
+
# response
|
|
405
|
+
if self._auth_response and self._auth_response.tokens:
|
|
406
|
+
old_tokens = self._auth_response.tokens
|
|
407
|
+
if (
|
|
408
|
+
not new_tokens.access_key_id
|
|
409
|
+
and old_tokens.access_key_id
|
|
410
|
+
):
|
|
411
|
+
new_tokens.access_key_id = old_tokens.access_key_id
|
|
412
|
+
if not new_tokens.secret_key and old_tokens.secret_key:
|
|
413
|
+
new_tokens.secret_key = old_tokens.secret_key
|
|
414
|
+
if (
|
|
415
|
+
not new_tokens.session_token
|
|
416
|
+
and old_tokens.session_token
|
|
417
|
+
):
|
|
418
|
+
new_tokens.session_token = old_tokens.session_token
|
|
419
|
+
if (
|
|
420
|
+
not new_tokens.authorization_expires_in
|
|
421
|
+
and old_tokens.authorization_expires_in
|
|
422
|
+
):
|
|
423
|
+
new_tokens.authorization_expires_in = (
|
|
424
|
+
old_tokens.authorization_expires_in
|
|
425
|
+
)
|
|
426
|
+
|
|
403
427
|
# Update stored auth response if we have one
|
|
404
428
|
if self._auth_response:
|
|
405
429
|
self._auth_response.tokens = new_tokens
|
|
@@ -130,6 +130,10 @@ async def async_main(args: argparse.Namespace) -> int:
|
|
|
130
130
|
_logger.info("Fetching device information...")
|
|
131
131
|
device = await api_client.get_first_device()
|
|
132
132
|
|
|
133
|
+
# Save tokens if they were refreshed during API call
|
|
134
|
+
if auth_client.current_tokens and auth_client.user_email:
|
|
135
|
+
save_tokens(auth_client.current_tokens, auth_client.user_email)
|
|
136
|
+
|
|
133
137
|
if not device:
|
|
134
138
|
_logger.error("No devices found for this account.")
|
|
135
139
|
return 1
|
|
@@ -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:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|