nwp500-python 3.1.4__tar.gz → 4.7__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.4 → nwp500_python-4.7}/.github/copilot-instructions.md +8 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/CHANGELOG.rst +47 -0
- {nwp500_python-3.1.4/src/nwp500_python.egg-info → nwp500_python-4.7}/PKG-INFO +1 -1
- {nwp500_python-3.1.4 → nwp500_python-4.7}/setup.cfg +1 -1
- {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/auth.py +37 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/events.py +5 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/mqtt_client.py +124 -14
- {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/mqtt_connection.py +3 -2
- {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/mqtt_periodic.py +3 -3
- {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/mqtt_reconnection.py +81 -23
- {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/mqtt_subscriptions.py +72 -8
- {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/mqtt_utils.py +7 -1
- {nwp500_python-3.1.4 → nwp500_python-4.7/src/nwp500_python.egg-info}/PKG-INFO +1 -1
- {nwp500_python-3.1.4 → nwp500_python-4.7}/.coveragerc +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/.github/workflows/ci.yml +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/.github/workflows/release.yml +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/.gitignore +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/.pre-commit-config.yaml +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/.readthedocs.yml +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/AUTHORS.rst +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/CONTRIBUTING.rst +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/LICENSE.txt +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/Makefile +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/README.rst +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/RELEASE.md +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/Makefile +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/_static/.gitignore +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/authors.rst +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/changelog.rst +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/conf.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/configuration.rst +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/development/contributing.rst +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/development/history.rst +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/guides/auto_recovery.rst +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/guides/command_queue.rst +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/guides/energy_monitoring.rst +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/guides/event_system.rst +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/guides/reservations.rst +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/guides/time_of_use.rst +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/index.rst +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/installation.rst +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/license.rst +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/openapi.yaml +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/protocol/device_features.rst +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/protocol/device_status.rst +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/protocol/error_codes.rst +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/protocol/firmware_tracking.rst +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/protocol/mqtt_protocol.rst +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/protocol/rest_api.rst +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/python_api/api_client.rst +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/python_api/auth_client.rst +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/python_api/cli.rst +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/python_api/constants.rst +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/python_api/events.rst +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/python_api/exceptions.rst +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/python_api/models.rst +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/python_api/mqtt_client.rst +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/quickstart.rst +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/requirements.txt +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/.ruff.toml +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/README.md +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/anti_legionella_example.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/api_client_example.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/auth_constructor_example.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/authenticate.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/auto_recovery_example.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/combined_callbacks.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/command_queue_demo.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/device_feature_callback.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/device_status_callback.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/device_status_callback_debug.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/energy_usage_example.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/event_emitter_demo.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/improved_auth_pattern.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/mask.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/mqtt_client_example.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/periodic_device_info.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/periodic_requests.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/power_control_example.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/reconnection_demo.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/reservation_schedule_example.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/set_dhw_temperature_example.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/set_mode_example.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/simple_auto_recovery.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/simple_periodic_info.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/simple_periodic_status.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/test_api_client.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/test_mqtt_connection.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/test_mqtt_messaging.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/test_periodic_minimal.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/tou_openei_example.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/tou_schedule_example.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/pyproject.toml +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/scripts/format.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/scripts/lint.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/scripts/setup-dev.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/setup.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/__init__.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/api_client.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/cli/__init__.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/cli/__main__.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/cli/commands.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/cli/monitoring.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/cli/output_formatters.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/cli/token_storage.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/cli.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/config.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/constants.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/encoding.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/models.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/mqtt_command_queue.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/mqtt_device_control.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/py.typed +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/utils.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500_python.egg-info/SOURCES.txt +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500_python.egg-info/dependency_links.txt +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500_python.egg-info/entry_points.txt +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500_python.egg-info/not-zip-safe +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500_python.egg-info/requires.txt +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500_python.egg-info/top_level.txt +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/tests/conftest.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/tests/test_api_helpers.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/tests/test_auth.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/tests/test_command_queue.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/tests/test_events.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/tests/test_utils.py +0 -0
- {nwp500_python-3.1.4 → nwp500_python-4.7}/tox.ini +0 -0
|
@@ -43,6 +43,14 @@ Report the results of these checks in your final summary.
|
|
|
43
43
|
- **MQTT topics**: `cmd/{deviceType}/{deviceId}/ctrl` for control, `cmd/{deviceType}/{deviceId}/st` for status
|
|
44
44
|
- **Command queuing**: Commands sent while disconnected are queued and sent when reconnected
|
|
45
45
|
- **No base64 encoding/decoding** of MQTT payloads; all payloads are JSON-encoded/decoded
|
|
46
|
+
- **Exception handling**: Use specific exception types instead of catch-all `except Exception`. Common types:
|
|
47
|
+
- `AwsCrtError` - AWS IoT Core/MQTT errors
|
|
48
|
+
- `AuthenticationError`, `TokenRefreshError` - Authentication errors
|
|
49
|
+
- `RuntimeError` - Runtime state errors (not connected, etc.)
|
|
50
|
+
- `ValueError` - Invalid values or parameters
|
|
51
|
+
- `TypeError`, `AttributeError`, `KeyError` - Data structure errors
|
|
52
|
+
- `asyncio.CancelledError` - Task cancellation
|
|
53
|
+
- Only catch exceptions you can handle; let unexpected exceptions propagate
|
|
46
54
|
|
|
47
55
|
## Integration Points
|
|
48
56
|
- **AWS IoT Core**: MQTT client uses `awscrt` and `awsiot` libraries for connection and messaging
|
|
@@ -2,6 +2,53 @@
|
|
|
2
2
|
Changelog
|
|
3
3
|
=========
|
|
4
4
|
|
|
5
|
+
Version 4.7 (2025-10-27)
|
|
6
|
+
========================
|
|
7
|
+
|
|
8
|
+
Added
|
|
9
|
+
-----
|
|
10
|
+
|
|
11
|
+
- **MQTT Reconnection**: Two-tier reconnection strategy with unlimited retries
|
|
12
|
+
|
|
13
|
+
- Implemented quick reconnection (attempts 1-9) for fast recovery from transient network issues
|
|
14
|
+
- Implemented deep reconnection (every 10th attempt) with full connection rebuild and credential refresh
|
|
15
|
+
- Changed default ``max_reconnect_attempts`` from 10 to -1 (unlimited retries)
|
|
16
|
+
- Added ``deep_reconnect_threshold`` configuration parameter (default: 10)
|
|
17
|
+
- Added ``has_stored_credentials`` property to ``NavienAuthClient``
|
|
18
|
+
- Added ``re_authenticate()`` method to ``NavienAuthClient`` for credential-based re-authentication
|
|
19
|
+
- Added ``resubscribe_all()`` method to ``MqttSubscriptionManager`` for subscription recovery
|
|
20
|
+
- Deep reconnection now performs token refresh and falls back to full re-authentication if needed
|
|
21
|
+
- Deep reconnection automatically re-establishes all subscriptions after rebuild
|
|
22
|
+
- Connection now continues retrying indefinitely instead of giving up after 10 attempts
|
|
23
|
+
|
|
24
|
+
Improved
|
|
25
|
+
--------
|
|
26
|
+
|
|
27
|
+
- **Exception Handling**: Replaced 25 catch-all exception handlers with specific exception types
|
|
28
|
+
|
|
29
|
+
- ``mqtt_client.py``: Uses ``AwsCrtError``, ``AuthenticationError``, ``TokenRefreshError``, ``RuntimeError``, ``ValueError``, ``TypeError``, ``AttributeError``
|
|
30
|
+
- ``mqtt_reconnection.py``: Uses ``AwsCrtError``, ``RuntimeError``, ``ValueError``, ``TypeError``
|
|
31
|
+
- ``mqtt_connection.py``: Uses ``AwsCrtError``, ``RuntimeError``, ``ValueError``
|
|
32
|
+
- ``mqtt_subscriptions.py``: Uses ``AwsCrtError``, ``RuntimeError``, ``TypeError``, ``AttributeError``, ``KeyError``, ``ValueError``
|
|
33
|
+
- ``mqtt_periodic.py``: Uses ``AwsCrtError``, ``RuntimeError``
|
|
34
|
+
- ``events.py``: Retains ``Exception`` for user callbacks (documented as legitimate use case)
|
|
35
|
+
- Added exception handling guidelines to ``.github/copilot-instructions.md``
|
|
36
|
+
|
|
37
|
+
- **Code Quality**: Multiple readability and safety improvements
|
|
38
|
+
|
|
39
|
+
- Simplified nested conditions by extracting to local variables
|
|
40
|
+
- Added ``hasattr()`` checks before accessing ``AwsCrtError.name`` attribute
|
|
41
|
+
- Optimized ``resubscribe_all()`` to break after first failure per topic (reduces redundant error logs)
|
|
42
|
+
- Fixed subscription failure tracking to use sets for unique topic counting
|
|
43
|
+
- Improved code clarity with intermediate variables for complex boolean expressions
|
|
44
|
+
|
|
45
|
+
Fixed
|
|
46
|
+
-----
|
|
47
|
+
|
|
48
|
+
- **MQTT Reconnection**: Eliminated duplicate "Connection interrupted" log messages
|
|
49
|
+
|
|
50
|
+
- Removed duplicate logging from ``mqtt_client.py`` (kept in ``mqtt_reconnection.py``)
|
|
51
|
+
|
|
5
52
|
Version 3.1.4 (2025-10-26)
|
|
6
53
|
==========================
|
|
7
54
|
|
|
@@ -471,6 +471,34 @@ class NavienAuthClient:
|
|
|
471
471
|
_logger.error(f"Failed to parse refresh response: {e}")
|
|
472
472
|
raise TokenRefreshError(f"Invalid response format: {str(e)}")
|
|
473
473
|
|
|
474
|
+
async def re_authenticate(self) -> AuthenticationResponse:
|
|
475
|
+
"""
|
|
476
|
+
Re-authenticate using stored credentials.
|
|
477
|
+
|
|
478
|
+
This is a convenience method that uses the stored user_id and password
|
|
479
|
+
from initialization to perform a fresh sign-in. Useful for recovering
|
|
480
|
+
from expired tokens or connection issues.
|
|
481
|
+
|
|
482
|
+
Returns:
|
|
483
|
+
AuthenticationResponse with fresh tokens and user info
|
|
484
|
+
|
|
485
|
+
Raises:
|
|
486
|
+
ValueError: If stored credentials are not available
|
|
487
|
+
AuthenticationError: If authentication fails
|
|
488
|
+
|
|
489
|
+
Example:
|
|
490
|
+
>>> client = NavienAuthClient(email, password)
|
|
491
|
+
>>> await client.re_authenticate() # Uses stored credentials
|
|
492
|
+
"""
|
|
493
|
+
if not self.has_stored_credentials:
|
|
494
|
+
raise ValueError(
|
|
495
|
+
"No stored credentials available for re-authentication. "
|
|
496
|
+
"Credentials must be provided during initialization."
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
_logger.info("Re-authenticating with stored credentials")
|
|
500
|
+
return await self.sign_in(self._user_id, self._password)
|
|
501
|
+
|
|
474
502
|
async def ensure_valid_token(self) -> Optional[AuthTokens]:
|
|
475
503
|
"""
|
|
476
504
|
Ensure we have a valid access token, refreshing if necessary.
|
|
@@ -526,6 +554,15 @@ class NavienAuthClient:
|
|
|
526
554
|
"""Get the email address of the authenticated user."""
|
|
527
555
|
return self._user_email
|
|
528
556
|
|
|
557
|
+
@property
|
|
558
|
+
def has_stored_credentials(self) -> bool:
|
|
559
|
+
"""Check if user credentials are stored for re-authentication.
|
|
560
|
+
|
|
561
|
+
Returns:
|
|
562
|
+
True if both user_id and password are available for re-auth
|
|
563
|
+
"""
|
|
564
|
+
return bool(self._user_id and self._password)
|
|
565
|
+
|
|
529
566
|
async def close(self) -> None:
|
|
530
567
|
"""Close the aiohttp session if we own it."""
|
|
531
568
|
if self._owned_session and self._session:
|
|
@@ -253,6 +253,11 @@ class EventEmitter:
|
|
|
253
253
|
self._once_callbacks.discard((event, listener.callback))
|
|
254
254
|
|
|
255
255
|
except Exception as e:
|
|
256
|
+
# Catch all exceptions from user callbacks to ensure
|
|
257
|
+
# resilience. We intentionally catch Exception here because:
|
|
258
|
+
# 1. User callbacks can raise any exception type
|
|
259
|
+
# 2. One bad callback shouldn't break other callbacks
|
|
260
|
+
# 3. This is an event emitter pattern where resilience is key
|
|
256
261
|
_logger.error(
|
|
257
262
|
f"Error in '{event}' event handler: {e}",
|
|
258
263
|
exc_info=True,
|
|
@@ -19,7 +19,11 @@ from typing import Any, Callable, Optional
|
|
|
19
19
|
from awscrt import mqtt
|
|
20
20
|
from awscrt.exceptions import AwsCrtError
|
|
21
21
|
|
|
22
|
-
from .auth import
|
|
22
|
+
from .auth import (
|
|
23
|
+
AuthenticationError,
|
|
24
|
+
NavienAuthClient,
|
|
25
|
+
TokenRefreshError,
|
|
26
|
+
)
|
|
23
27
|
from .events import EventEmitter
|
|
24
28
|
from .models import (
|
|
25
29
|
Device,
|
|
@@ -205,7 +209,8 @@ class NavienMqttClient(EventEmitter):
|
|
|
205
209
|
# Schedule the coroutine in the stored loop using thread-safe method
|
|
206
210
|
try:
|
|
207
211
|
asyncio.run_coroutine_threadsafe(coro, self._loop)
|
|
208
|
-
except
|
|
212
|
+
except RuntimeError as e:
|
|
213
|
+
# Event loop is closed or not running
|
|
209
214
|
_logger.error(f"Failed to schedule coroutine: {e}", exc_info=True)
|
|
210
215
|
|
|
211
216
|
def _on_connection_interrupted_internal(
|
|
@@ -218,7 +223,6 @@ class NavienMqttClient(EventEmitter):
|
|
|
218
223
|
error: Error that caused the interruption
|
|
219
224
|
**kwargs: Forward-compatibility kwargs from AWS SDK
|
|
220
225
|
"""
|
|
221
|
-
_logger.warning(f"Connection interrupted: {error}")
|
|
222
226
|
self._connected = False
|
|
223
227
|
|
|
224
228
|
# Emit event
|
|
@@ -232,7 +236,7 @@ class NavienMqttClient(EventEmitter):
|
|
|
232
236
|
# Fallback for callbacks expecting no arguments
|
|
233
237
|
try:
|
|
234
238
|
self._on_connection_interrupted() # type: ignore
|
|
235
|
-
except
|
|
239
|
+
except (TypeError, AttributeError) as e:
|
|
236
240
|
_logger.error(
|
|
237
241
|
f"Error in connection_interrupted callback: {e}"
|
|
238
242
|
)
|
|
@@ -339,12 +343,113 @@ class NavienMqttClient(EventEmitter):
|
|
|
339
343
|
"No connection manager available for reconnection"
|
|
340
344
|
)
|
|
341
345
|
|
|
342
|
-
except
|
|
346
|
+
except (AwsCrtError, AuthenticationError, RuntimeError) as e:
|
|
343
347
|
_logger.error(
|
|
344
348
|
f"Error during active reconnection: {e}", exc_info=True
|
|
345
349
|
)
|
|
346
350
|
raise
|
|
347
351
|
|
|
352
|
+
async def _deep_reconnect(self) -> None:
|
|
353
|
+
"""
|
|
354
|
+
Perform a deep reconnection by completely rebuilding the connection.
|
|
355
|
+
|
|
356
|
+
This method is called after multiple quick reconnection failures.
|
|
357
|
+
It performs a full teardown and rebuild:
|
|
358
|
+
- Disconnects existing connection
|
|
359
|
+
- Refreshes authentication tokens
|
|
360
|
+
- Creates new connection manager
|
|
361
|
+
- Re-establishes all subscriptions
|
|
362
|
+
|
|
363
|
+
This is more expensive but can recover from issues that a simple
|
|
364
|
+
reconnection cannot fix (e.g., stale credentials, corrupted state).
|
|
365
|
+
"""
|
|
366
|
+
if self._connected:
|
|
367
|
+
_logger.debug("Already connected, skipping deep reconnection")
|
|
368
|
+
return
|
|
369
|
+
|
|
370
|
+
_logger.warning(
|
|
371
|
+
"Performing deep reconnection (full rebuild)... "
|
|
372
|
+
"This may take longer."
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
try:
|
|
376
|
+
# Step 1: Clean up existing connection if any
|
|
377
|
+
if self._connection_manager:
|
|
378
|
+
_logger.debug("Cleaning up old connection...")
|
|
379
|
+
try:
|
|
380
|
+
if self._connection_manager.is_connected:
|
|
381
|
+
await self._connection_manager.disconnect()
|
|
382
|
+
except (AwsCrtError, RuntimeError) as e:
|
|
383
|
+
# Expected: connection already dead or in bad state
|
|
384
|
+
_logger.debug(f"Error during cleanup: {e} (expected)")
|
|
385
|
+
|
|
386
|
+
# Step 2: Force token refresh to get fresh AWS credentials
|
|
387
|
+
_logger.debug("Refreshing authentication tokens...")
|
|
388
|
+
try:
|
|
389
|
+
# Use the stored refresh token from current tokens
|
|
390
|
+
current_tokens = self._auth_client.current_tokens
|
|
391
|
+
if current_tokens and current_tokens.refresh_token:
|
|
392
|
+
await self._auth_client.refresh_token(
|
|
393
|
+
current_tokens.refresh_token
|
|
394
|
+
)
|
|
395
|
+
else:
|
|
396
|
+
_logger.warning("No refresh token available")
|
|
397
|
+
raise ValueError("No refresh token available for refresh")
|
|
398
|
+
except (TokenRefreshError, ValueError, AuthenticationError) as e:
|
|
399
|
+
# If refresh fails, try full re-authentication with stored
|
|
400
|
+
# credentials
|
|
401
|
+
if self._auth_client.has_stored_credentials:
|
|
402
|
+
_logger.warning(
|
|
403
|
+
f"Token refresh failed: {e}. Attempting full "
|
|
404
|
+
"re-authentication..."
|
|
405
|
+
)
|
|
406
|
+
await self._auth_client.re_authenticate()
|
|
407
|
+
else:
|
|
408
|
+
_logger.error(
|
|
409
|
+
"Cannot re-authenticate: no stored credentials"
|
|
410
|
+
)
|
|
411
|
+
raise
|
|
412
|
+
|
|
413
|
+
# Step 3: Create completely new connection manager
|
|
414
|
+
_logger.debug("Creating new connection manager...")
|
|
415
|
+
self._connection_manager = MqttConnection(
|
|
416
|
+
config=self.config,
|
|
417
|
+
auth_client=self._auth_client,
|
|
418
|
+
on_connection_interrupted=self._on_connection_interrupted_internal,
|
|
419
|
+
on_connection_resumed=self._on_connection_resumed_internal,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
# Step 4: Attempt connection
|
|
423
|
+
success = await self._connection_manager.connect()
|
|
424
|
+
|
|
425
|
+
if success:
|
|
426
|
+
# Update connection references
|
|
427
|
+
self._connection = self._connection_manager.connection
|
|
428
|
+
self._connected = True
|
|
429
|
+
|
|
430
|
+
# Step 5: Re-establish subscriptions
|
|
431
|
+
if self._subscription_manager and self._connection:
|
|
432
|
+
_logger.debug("Re-establishing subscriptions...")
|
|
433
|
+
self._subscription_manager.update_connection(
|
|
434
|
+
self._connection
|
|
435
|
+
)
|
|
436
|
+
await self._subscription_manager.resubscribe_all()
|
|
437
|
+
|
|
438
|
+
_logger.info(
|
|
439
|
+
"Deep reconnection successful - fully rebuilt connection"
|
|
440
|
+
)
|
|
441
|
+
else:
|
|
442
|
+
_logger.error("Deep reconnection failed to connect")
|
|
443
|
+
|
|
444
|
+
except (
|
|
445
|
+
AwsCrtError,
|
|
446
|
+
AuthenticationError,
|
|
447
|
+
RuntimeError,
|
|
448
|
+
ValueError,
|
|
449
|
+
) as e:
|
|
450
|
+
_logger.error(f"Error during deep reconnection: {e}", exc_info=True)
|
|
451
|
+
raise
|
|
452
|
+
|
|
348
453
|
async def connect(self) -> bool:
|
|
349
454
|
"""
|
|
350
455
|
Establish connection to AWS IoT Core.
|
|
@@ -394,6 +499,7 @@ class NavienMqttClient(EventEmitter):
|
|
|
394
499
|
is_connected_func=lambda: self._connected,
|
|
395
500
|
schedule_coroutine_func=self._schedule_coroutine,
|
|
396
501
|
reconnect_func=self._active_reconnect,
|
|
502
|
+
deep_reconnect_func=self._deep_reconnect,
|
|
397
503
|
emit_event_func=self.emit,
|
|
398
504
|
)
|
|
399
505
|
self._reconnection_handler.enable()
|
|
@@ -428,7 +534,12 @@ class NavienMqttClient(EventEmitter):
|
|
|
428
534
|
|
|
429
535
|
return False
|
|
430
536
|
|
|
431
|
-
except
|
|
537
|
+
except (
|
|
538
|
+
AwsCrtError,
|
|
539
|
+
AuthenticationError,
|
|
540
|
+
RuntimeError,
|
|
541
|
+
ValueError,
|
|
542
|
+
) as e:
|
|
432
543
|
_logger.error(f"Failed to connect: {e}")
|
|
433
544
|
raise
|
|
434
545
|
|
|
@@ -473,7 +584,7 @@ class NavienMqttClient(EventEmitter):
|
|
|
473
584
|
self._connection = None
|
|
474
585
|
|
|
475
586
|
_logger.info("Disconnected successfully")
|
|
476
|
-
except
|
|
587
|
+
except (AwsCrtError, RuntimeError) as e:
|
|
477
588
|
_logger.error(f"Error during disconnect: {e}")
|
|
478
589
|
raise
|
|
479
590
|
|
|
@@ -493,7 +604,7 @@ class NavienMqttClient(EventEmitter):
|
|
|
493
604
|
|
|
494
605
|
except json.JSONDecodeError as e:
|
|
495
606
|
_logger.error(f"Failed to parse message payload: {e}")
|
|
496
|
-
except
|
|
607
|
+
except (AttributeError, KeyError, TypeError) as e:
|
|
497
608
|
_logger.error(f"Error processing message: {e}")
|
|
498
609
|
|
|
499
610
|
def _topic_matches_pattern(self, topic: str, pattern: str) -> bool:
|
|
@@ -618,12 +729,11 @@ class NavienMqttClient(EventEmitter):
|
|
|
618
729
|
|
|
619
730
|
try:
|
|
620
731
|
return await self._connection_manager.publish(topic, payload, qos)
|
|
621
|
-
except
|
|
732
|
+
except AwsCrtError as e:
|
|
622
733
|
# Handle clean session cancellation gracefully
|
|
623
|
-
#
|
|
624
|
-
# error identification
|
|
734
|
+
# Safely check e.name attribute (may not exist or be None)
|
|
625
735
|
if (
|
|
626
|
-
|
|
736
|
+
hasattr(e, "name")
|
|
627
737
|
and e.name == "AWS_ERROR_MQTT_CANCELLED_FOR_CLEAN_SESSION"
|
|
628
738
|
):
|
|
629
739
|
_logger.warning(
|
|
@@ -641,9 +751,9 @@ class NavienMqttClient(EventEmitter):
|
|
|
641
751
|
raise RuntimeError(
|
|
642
752
|
"Publish cancelled due to clean session and "
|
|
643
753
|
"command queue is disabled"
|
|
644
|
-
)
|
|
754
|
+
) from e
|
|
645
755
|
|
|
646
|
-
#
|
|
756
|
+
# Other AWS CRT errors
|
|
647
757
|
_logger.error(f"Failed to publish to topic: {e}")
|
|
648
758
|
raise
|
|
649
759
|
|
|
@@ -12,6 +12,7 @@ import logging
|
|
|
12
12
|
from typing import TYPE_CHECKING, Any, Callable, Optional, Union
|
|
13
13
|
|
|
14
14
|
from awscrt import mqtt
|
|
15
|
+
from awscrt.exceptions import AwsCrtError
|
|
15
16
|
from awsiot import mqtt_connection_builder
|
|
16
17
|
|
|
17
18
|
if TYPE_CHECKING:
|
|
@@ -147,7 +148,7 @@ class MqttConnection:
|
|
|
147
148
|
|
|
148
149
|
return True
|
|
149
150
|
|
|
150
|
-
except
|
|
151
|
+
except (AwsCrtError, RuntimeError, ValueError) as e:
|
|
151
152
|
_logger.error(f"Failed to connect: {e}")
|
|
152
153
|
raise
|
|
153
154
|
|
|
@@ -195,7 +196,7 @@ class MqttConnection:
|
|
|
195
196
|
self._connected = False
|
|
196
197
|
self._connection = None
|
|
197
198
|
_logger.info("Disconnected successfully")
|
|
198
|
-
except
|
|
199
|
+
except (AwsCrtError, RuntimeError) as e:
|
|
199
200
|
_logger.error(f"Error during disconnect: {e}")
|
|
200
201
|
raise
|
|
201
202
|
|
|
@@ -186,13 +186,13 @@ class MqttPeriodicRequestManager:
|
|
|
186
186
|
f"for {redacted_device_id}"
|
|
187
187
|
)
|
|
188
188
|
break
|
|
189
|
-
except
|
|
189
|
+
except (AwsCrtError, RuntimeError) as e:
|
|
190
190
|
# Handle clean session cancellation gracefully (expected
|
|
191
191
|
# during reconnection)
|
|
192
|
-
#
|
|
193
|
-
# identification
|
|
192
|
+
# Safely check exception name attribute
|
|
194
193
|
if (
|
|
195
194
|
isinstance(e, AwsCrtError)
|
|
195
|
+
and hasattr(e, "name")
|
|
196
196
|
and e.name
|
|
197
197
|
== "AWS_ERROR_MQTT_CANCELLED_FOR_CLEAN_SESSION"
|
|
198
198
|
):
|
|
@@ -11,6 +11,8 @@ import logging
|
|
|
11
11
|
from collections.abc import Awaitable
|
|
12
12
|
from typing import TYPE_CHECKING, Any, Callable, Optional
|
|
13
13
|
|
|
14
|
+
from awscrt.exceptions import AwsCrtError
|
|
15
|
+
|
|
14
16
|
if TYPE_CHECKING:
|
|
15
17
|
from .mqtt_utils import MqttConnectionConfig
|
|
16
18
|
|
|
@@ -35,6 +37,7 @@ class MqttReconnectionHandler:
|
|
|
35
37
|
is_connected_func: Callable[[], bool],
|
|
36
38
|
schedule_coroutine_func: Callable[[Any], None],
|
|
37
39
|
reconnect_func: Callable[[], Awaitable[None]],
|
|
40
|
+
deep_reconnect_func: Optional[Callable[[], Awaitable[None]]] = None,
|
|
38
41
|
emit_event_func: Optional[Callable[..., Awaitable[Any]]] = None,
|
|
39
42
|
):
|
|
40
43
|
"""
|
|
@@ -46,6 +49,8 @@ class MqttReconnectionHandler:
|
|
|
46
49
|
schedule_coroutine_func: Function to schedule coroutines from any
|
|
47
50
|
thread
|
|
48
51
|
reconnect_func: Async function to trigger active reconnection
|
|
52
|
+
deep_reconnect_func: Optional async function to trigger deep
|
|
53
|
+
reconnection (full rebuild)
|
|
49
54
|
emit_event_func: Optional async function to emit events
|
|
50
55
|
(e.g., EventEmitter.emit)
|
|
51
56
|
"""
|
|
@@ -53,6 +58,7 @@ class MqttReconnectionHandler:
|
|
|
53
58
|
self._is_connected_func = is_connected_func
|
|
54
59
|
self._schedule_coroutine = schedule_coroutine_func
|
|
55
60
|
self._reconnect_func = reconnect_func
|
|
61
|
+
self._deep_reconnect_func = deep_reconnect_func
|
|
56
62
|
self._emit_event = emit_event_func
|
|
57
63
|
|
|
58
64
|
self._reconnect_attempts = 0
|
|
@@ -135,15 +141,38 @@ class MqttReconnectionHandler:
|
|
|
135
141
|
Attempt to reconnect with exponential backoff.
|
|
136
142
|
|
|
137
143
|
This method is called automatically when connection is interrupted
|
|
138
|
-
if auto_reconnect is enabled.
|
|
144
|
+
if auto_reconnect is enabled. Supports unlimited retries when
|
|
145
|
+
max_reconnect_attempts is -1.
|
|
146
|
+
|
|
147
|
+
Uses a two-tier strategy:
|
|
148
|
+
- Quick reconnects (attempts 1-N): Fast reconnection with existing setup
|
|
149
|
+
- Deep reconnects (attempts N+): Full rebuild including token refresh
|
|
139
150
|
"""
|
|
151
|
+
unlimited_retries = self.config.max_reconnect_attempts < 0
|
|
152
|
+
|
|
140
153
|
while (
|
|
141
154
|
not self._is_connected_func()
|
|
142
155
|
and not self._manual_disconnect
|
|
143
|
-
and
|
|
156
|
+
and (
|
|
157
|
+
unlimited_retries
|
|
158
|
+
or self._reconnect_attempts < self.config.max_reconnect_attempts
|
|
159
|
+
)
|
|
144
160
|
):
|
|
145
161
|
self._reconnect_attempts += 1
|
|
146
162
|
|
|
163
|
+
# Determine if we should do a deep reconnection
|
|
164
|
+
has_deep_reconnect = self._deep_reconnect_func is not None
|
|
165
|
+
is_at_threshold = (
|
|
166
|
+
self._reconnect_attempts >= self.config.deep_reconnect_threshold
|
|
167
|
+
)
|
|
168
|
+
is_threshold_multiple = (
|
|
169
|
+
self._reconnect_attempts % self.config.deep_reconnect_threshold
|
|
170
|
+
== 0
|
|
171
|
+
)
|
|
172
|
+
use_deep_reconnect = (
|
|
173
|
+
has_deep_reconnect and is_at_threshold and is_threshold_multiple
|
|
174
|
+
)
|
|
175
|
+
|
|
147
176
|
# Calculate delay with exponential backoff
|
|
148
177
|
delay = min(
|
|
149
178
|
self.config.initial_reconnect_delay
|
|
@@ -154,12 +183,21 @@ class MqttReconnectionHandler:
|
|
|
154
183
|
self.config.max_reconnect_delay,
|
|
155
184
|
)
|
|
156
185
|
|
|
157
|
-
|
|
158
|
-
"
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
186
|
+
if unlimited_retries:
|
|
187
|
+
reconnect_type = "deep" if use_deep_reconnect else "quick"
|
|
188
|
+
_logger.info(
|
|
189
|
+
"Reconnection attempt %d (%s) in %.1f seconds...",
|
|
190
|
+
self._reconnect_attempts,
|
|
191
|
+
reconnect_type,
|
|
192
|
+
delay,
|
|
193
|
+
)
|
|
194
|
+
else:
|
|
195
|
+
_logger.info(
|
|
196
|
+
"Reconnection attempt %d/%d in %.1f seconds...",
|
|
197
|
+
self._reconnect_attempts,
|
|
198
|
+
self.config.max_reconnect_attempts,
|
|
199
|
+
delay,
|
|
200
|
+
)
|
|
163
201
|
|
|
164
202
|
try:
|
|
165
203
|
await asyncio.sleep(delay)
|
|
@@ -171,30 +209,50 @@ class MqttReconnectionHandler:
|
|
|
171
209
|
)
|
|
172
210
|
break
|
|
173
211
|
|
|
174
|
-
# Trigger
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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."
|
|
212
|
+
# Trigger appropriate reconnection type
|
|
213
|
+
if use_deep_reconnect and self._deep_reconnect_func is not None:
|
|
214
|
+
_logger.info(
|
|
215
|
+
"Triggering deep reconnection "
|
|
216
|
+
"(full rebuild with token refresh)..."
|
|
185
217
|
)
|
|
218
|
+
try:
|
|
219
|
+
await self._deep_reconnect_func()
|
|
220
|
+
if self._is_connected_func():
|
|
221
|
+
_logger.info(
|
|
222
|
+
"Successfully reconnected via deep reconnection"
|
|
223
|
+
)
|
|
224
|
+
break
|
|
225
|
+
except (AwsCrtError, RuntimeError, ValueError) as e:
|
|
226
|
+
_logger.warning(
|
|
227
|
+
f"Deep reconnection failed: {e}. Will retry..."
|
|
228
|
+
)
|
|
229
|
+
else:
|
|
230
|
+
_logger.info("Triggering quick reconnection...")
|
|
231
|
+
try:
|
|
232
|
+
await self._reconnect_func()
|
|
233
|
+
if self._is_connected_func():
|
|
234
|
+
_logger.info(
|
|
235
|
+
"Successfully reconnected via "
|
|
236
|
+
"quick reconnection"
|
|
237
|
+
)
|
|
238
|
+
break
|
|
239
|
+
except (AwsCrtError, RuntimeError) as e:
|
|
240
|
+
_logger.warning(
|
|
241
|
+
f"Quick reconnection failed: {e}. Will retry..."
|
|
242
|
+
)
|
|
186
243
|
|
|
187
244
|
except asyncio.CancelledError:
|
|
188
245
|
_logger.info("Reconnection task cancelled")
|
|
189
246
|
break
|
|
190
|
-
except
|
|
247
|
+
except (AwsCrtError, RuntimeError) as e:
|
|
191
248
|
_logger.error(
|
|
192
249
|
f"Error during reconnection attempt: {e}", exc_info=True
|
|
193
250
|
)
|
|
194
251
|
|
|
195
|
-
# Check final state
|
|
252
|
+
# Check final state (only if not unlimited retries)
|
|
196
253
|
if (
|
|
197
|
-
|
|
254
|
+
not unlimited_retries
|
|
255
|
+
and self._reconnect_attempts >= self.config.max_reconnect_attempts
|
|
198
256
|
and not self._is_connected_func()
|
|
199
257
|
):
|
|
200
258
|
_logger.error(
|
|
@@ -208,7 +266,7 @@ class MqttReconnectionHandler:
|
|
|
208
266
|
await self._emit_event(
|
|
209
267
|
"reconnection_failed", self._reconnect_attempts
|
|
210
268
|
)
|
|
211
|
-
except
|
|
269
|
+
except (TypeError, RuntimeError) as e:
|
|
212
270
|
_logger.error(
|
|
213
271
|
f"Error emitting reconnection_failed event: {e}"
|
|
214
272
|
)
|
|
@@ -15,6 +15,7 @@ import logging
|
|
|
15
15
|
from typing import Any, Callable, Optional
|
|
16
16
|
|
|
17
17
|
from awscrt import mqtt
|
|
18
|
+
from awscrt.exceptions import AwsCrtError
|
|
18
19
|
|
|
19
20
|
from .events import EventEmitter
|
|
20
21
|
from .models import Device, DeviceFeature, DeviceStatus, EnergyUsageResponse
|
|
@@ -117,12 +118,12 @@ class MqttSubscriptionManager:
|
|
|
117
118
|
for handler in handlers:
|
|
118
119
|
try:
|
|
119
120
|
handler(topic, message)
|
|
120
|
-
except
|
|
121
|
+
except (TypeError, AttributeError, KeyError) as e:
|
|
121
122
|
_logger.error(f"Error in message handler: {e}")
|
|
122
123
|
|
|
123
124
|
except json.JSONDecodeError as e:
|
|
124
125
|
_logger.error(f"Failed to parse message payload: {e}")
|
|
125
|
-
except
|
|
126
|
+
except (AttributeError, KeyError, TypeError) as e:
|
|
126
127
|
_logger.error(f"Error processing message: {e}")
|
|
127
128
|
|
|
128
129
|
def _topic_matches_pattern(self, topic: str, pattern: str) -> bool:
|
|
@@ -230,7 +231,7 @@ class MqttSubscriptionManager:
|
|
|
230
231
|
|
|
231
232
|
return int(packet_id)
|
|
232
233
|
|
|
233
|
-
except
|
|
234
|
+
except (AwsCrtError, RuntimeError) as e:
|
|
234
235
|
_logger.error(
|
|
235
236
|
f"Failed to subscribe to '{redact_topic(topic)}': {e}"
|
|
236
237
|
)
|
|
@@ -268,12 +269,75 @@ class MqttSubscriptionManager:
|
|
|
268
269
|
|
|
269
270
|
return int(packet_id)
|
|
270
271
|
|
|
271
|
-
except
|
|
272
|
+
except (AwsCrtError, RuntimeError) as e:
|
|
272
273
|
_logger.error(
|
|
273
274
|
f"Failed to unsubscribe from '{redact_topic(topic)}': {e}"
|
|
274
275
|
)
|
|
275
276
|
raise
|
|
276
277
|
|
|
278
|
+
async def resubscribe_all(self) -> None:
|
|
279
|
+
"""
|
|
280
|
+
Re-establish all subscriptions after a connection rebuild.
|
|
281
|
+
|
|
282
|
+
This method is called after a deep reconnection to restore all
|
|
283
|
+
active subscriptions. It uses the stored subscription information
|
|
284
|
+
to re-subscribe to all topics with their original QoS settings
|
|
285
|
+
and handlers.
|
|
286
|
+
|
|
287
|
+
Note:
|
|
288
|
+
This is typically called automatically during deep reconnection
|
|
289
|
+
and should not need to be called manually.
|
|
290
|
+
|
|
291
|
+
Raises:
|
|
292
|
+
RuntimeError: If not connected to MQTT broker
|
|
293
|
+
Exception: If any subscription fails
|
|
294
|
+
"""
|
|
295
|
+
if not self._connection:
|
|
296
|
+
raise RuntimeError("Not connected to MQTT broker")
|
|
297
|
+
|
|
298
|
+
if not self._subscriptions:
|
|
299
|
+
_logger.debug("No subscriptions to restore")
|
|
300
|
+
return
|
|
301
|
+
|
|
302
|
+
subscription_count = len(self._subscriptions)
|
|
303
|
+
_logger.info(f"Re-establishing {subscription_count} subscription(s)...")
|
|
304
|
+
|
|
305
|
+
# Store subscriptions to re-establish (avoid modifying dict during
|
|
306
|
+
# iteration)
|
|
307
|
+
subscriptions_to_restore = list(self._subscriptions.items())
|
|
308
|
+
handlers_to_restore = {
|
|
309
|
+
topic: handlers.copy()
|
|
310
|
+
for topic, handlers in self._message_handlers.items()
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
# Clear current subscriptions (will be re-added by subscribe())
|
|
314
|
+
self._subscriptions.clear()
|
|
315
|
+
self._message_handlers.clear()
|
|
316
|
+
|
|
317
|
+
# Re-establish each subscription
|
|
318
|
+
failed_subscriptions = set()
|
|
319
|
+
for topic, qos in subscriptions_to_restore:
|
|
320
|
+
handlers = handlers_to_restore.get(topic, [])
|
|
321
|
+
for handler in handlers:
|
|
322
|
+
try:
|
|
323
|
+
await self.subscribe(topic, handler, qos)
|
|
324
|
+
except (AwsCrtError, RuntimeError) as e:
|
|
325
|
+
_logger.error(
|
|
326
|
+
f"Failed to re-subscribe to "
|
|
327
|
+
f"'{redact_topic(topic)}': {e}"
|
|
328
|
+
)
|
|
329
|
+
# Mark topic as failed and skip remaining handlers
|
|
330
|
+
# since they will fail for the same reason
|
|
331
|
+
failed_subscriptions.add(topic)
|
|
332
|
+
break # Exit handler loop, move to next topic
|
|
333
|
+
|
|
334
|
+
if failed_subscriptions:
|
|
335
|
+
_logger.warning(
|
|
336
|
+
f"Failed to restore {len(failed_subscriptions)} subscription(s)"
|
|
337
|
+
)
|
|
338
|
+
else:
|
|
339
|
+
_logger.info("All subscriptions re-established successfully")
|
|
340
|
+
|
|
277
341
|
async def subscribe_device(
|
|
278
342
|
self, device: Device, callback: Callable[[str, dict[str, Any]], None]
|
|
279
343
|
) -> int:
|
|
@@ -405,7 +469,7 @@ class MqttSubscriptionManager:
|
|
|
405
469
|
_logger.warning(
|
|
406
470
|
f"Invalid value in status message: {e}", exc_info=True
|
|
407
471
|
)
|
|
408
|
-
except
|
|
472
|
+
except (TypeError, AttributeError) as e:
|
|
409
473
|
_logger.error(
|
|
410
474
|
f"Error parsing device status: {e}", exc_info=True
|
|
411
475
|
)
|
|
@@ -492,7 +556,7 @@ class MqttSubscriptionManager:
|
|
|
492
556
|
await self._event_emitter.emit("error_cleared", prev.errorCode)
|
|
493
557
|
_logger.info(f"Error cleared: {prev.errorCode}")
|
|
494
558
|
|
|
495
|
-
except
|
|
559
|
+
except (TypeError, AttributeError, RuntimeError) as e:
|
|
496
560
|
_logger.error(f"Error detecting state changes: {e}", exc_info=True)
|
|
497
561
|
finally:
|
|
498
562
|
# Always update previous status
|
|
@@ -594,7 +658,7 @@ class MqttSubscriptionManager:
|
|
|
594
658
|
_logger.warning(
|
|
595
659
|
f"Invalid value in feature message: {e}", exc_info=True
|
|
596
660
|
)
|
|
597
|
-
except
|
|
661
|
+
except (TypeError, AttributeError) as e:
|
|
598
662
|
_logger.error(
|
|
599
663
|
f"Error parsing device feature: {e}", exc_info=True
|
|
600
664
|
)
|
|
@@ -684,7 +748,7 @@ class MqttSubscriptionManager:
|
|
|
684
748
|
_logger.warning(
|
|
685
749
|
"Failed to parse energy usage message - missing key: %s", e
|
|
686
750
|
)
|
|
687
|
-
except
|
|
751
|
+
except (TypeError, ValueError, AttributeError) as e:
|
|
688
752
|
_logger.error(
|
|
689
753
|
"Error in energy usage message handler: %s",
|
|
690
754
|
e,
|
|
@@ -153,9 +153,12 @@ class MqttConnectionConfig:
|
|
|
153
153
|
|
|
154
154
|
auto_reconnect: Enable automatic reconnection
|
|
155
155
|
max_reconnect_attempts: Maximum reconnection attempts
|
|
156
|
+
(-1 for unlimited)
|
|
156
157
|
initial_reconnect_delay: Initial delay between reconnect attempts
|
|
157
158
|
max_reconnect_delay: Maximum delay between reconnect attempts
|
|
158
159
|
reconnect_backoff_multiplier: Exponential backoff multiplier
|
|
160
|
+
deep_reconnect_threshold: Attempt count to trigger full
|
|
161
|
+
connection rebuild
|
|
159
162
|
|
|
160
163
|
enable_command_queue: Enable command queueing when disconnected
|
|
161
164
|
max_queued_commands: Maximum number of queued commands
|
|
@@ -169,10 +172,13 @@ class MqttConnectionConfig:
|
|
|
169
172
|
|
|
170
173
|
# Reconnection settings
|
|
171
174
|
auto_reconnect: bool = True
|
|
172
|
-
max_reconnect_attempts: int =
|
|
175
|
+
max_reconnect_attempts: int = -1 # -1 = unlimited retries
|
|
173
176
|
initial_reconnect_delay: float = 1.0 # seconds
|
|
174
177
|
max_reconnect_delay: float = 120.0 # seconds
|
|
175
178
|
reconnect_backoff_multiplier: float = 2.0
|
|
179
|
+
deep_reconnect_threshold: int = (
|
|
180
|
+
10 # Switch to full rebuild after N attempts
|
|
181
|
+
)
|
|
176
182
|
|
|
177
183
|
# Command queue settings
|
|
178
184
|
enable_command_queue: bool = True
|
|
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
|