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.
Files changed (127) hide show
  1. {nwp500_python-3.1.4 → nwp500_python-4.7}/.github/copilot-instructions.md +8 -0
  2. {nwp500_python-3.1.4 → nwp500_python-4.7}/CHANGELOG.rst +47 -0
  3. {nwp500_python-3.1.4/src/nwp500_python.egg-info → nwp500_python-4.7}/PKG-INFO +1 -1
  4. {nwp500_python-3.1.4 → nwp500_python-4.7}/setup.cfg +1 -1
  5. {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/auth.py +37 -0
  6. {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/events.py +5 -0
  7. {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/mqtt_client.py +124 -14
  8. {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/mqtt_connection.py +3 -2
  9. {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/mqtt_periodic.py +3 -3
  10. {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/mqtt_reconnection.py +81 -23
  11. {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/mqtt_subscriptions.py +72 -8
  12. {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/mqtt_utils.py +7 -1
  13. {nwp500_python-3.1.4 → nwp500_python-4.7/src/nwp500_python.egg-info}/PKG-INFO +1 -1
  14. {nwp500_python-3.1.4 → nwp500_python-4.7}/.coveragerc +0 -0
  15. {nwp500_python-3.1.4 → nwp500_python-4.7}/.github/workflows/ci.yml +0 -0
  16. {nwp500_python-3.1.4 → nwp500_python-4.7}/.github/workflows/release.yml +0 -0
  17. {nwp500_python-3.1.4 → nwp500_python-4.7}/.gitignore +0 -0
  18. {nwp500_python-3.1.4 → nwp500_python-4.7}/.pre-commit-config.yaml +0 -0
  19. {nwp500_python-3.1.4 → nwp500_python-4.7}/.readthedocs.yml +0 -0
  20. {nwp500_python-3.1.4 → nwp500_python-4.7}/AUTHORS.rst +0 -0
  21. {nwp500_python-3.1.4 → nwp500_python-4.7}/CONTRIBUTING.rst +0 -0
  22. {nwp500_python-3.1.4 → nwp500_python-4.7}/LICENSE.txt +0 -0
  23. {nwp500_python-3.1.4 → nwp500_python-4.7}/Makefile +0 -0
  24. {nwp500_python-3.1.4 → nwp500_python-4.7}/README.rst +0 -0
  25. {nwp500_python-3.1.4 → nwp500_python-4.7}/RELEASE.md +0 -0
  26. {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/Makefile +0 -0
  27. {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/_static/.gitignore +0 -0
  28. {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/authors.rst +0 -0
  29. {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/changelog.rst +0 -0
  30. {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/conf.py +0 -0
  31. {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/configuration.rst +0 -0
  32. {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/development/contributing.rst +0 -0
  33. {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/development/history.rst +0 -0
  34. {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/guides/auto_recovery.rst +0 -0
  35. {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/guides/command_queue.rst +0 -0
  36. {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/guides/energy_monitoring.rst +0 -0
  37. {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/guides/event_system.rst +0 -0
  38. {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/guides/reservations.rst +0 -0
  39. {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/guides/time_of_use.rst +0 -0
  40. {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/index.rst +0 -0
  41. {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/installation.rst +0 -0
  42. {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/license.rst +0 -0
  43. {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/openapi.yaml +0 -0
  44. {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/protocol/device_features.rst +0 -0
  45. {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/protocol/device_status.rst +0 -0
  46. {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/protocol/error_codes.rst +0 -0
  47. {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/protocol/firmware_tracking.rst +0 -0
  48. {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/protocol/mqtt_protocol.rst +0 -0
  49. {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/protocol/rest_api.rst +0 -0
  50. {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/python_api/api_client.rst +0 -0
  51. {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/python_api/auth_client.rst +0 -0
  52. {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/python_api/cli.rst +0 -0
  53. {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/python_api/constants.rst +0 -0
  54. {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/python_api/events.rst +0 -0
  55. {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/python_api/exceptions.rst +0 -0
  56. {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/python_api/models.rst +0 -0
  57. {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/python_api/mqtt_client.rst +0 -0
  58. {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/quickstart.rst +0 -0
  59. {nwp500_python-3.1.4 → nwp500_python-4.7}/docs/requirements.txt +0 -0
  60. {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/.ruff.toml +0 -0
  61. {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/README.md +0 -0
  62. {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/anti_legionella_example.py +0 -0
  63. {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/api_client_example.py +0 -0
  64. {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/auth_constructor_example.py +0 -0
  65. {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/authenticate.py +0 -0
  66. {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/auto_recovery_example.py +0 -0
  67. {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/combined_callbacks.py +0 -0
  68. {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/command_queue_demo.py +0 -0
  69. {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/device_feature_callback.py +0 -0
  70. {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/device_status_callback.py +0 -0
  71. {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/device_status_callback_debug.py +0 -0
  72. {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/energy_usage_example.py +0 -0
  73. {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/event_emitter_demo.py +0 -0
  74. {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/improved_auth_pattern.py +0 -0
  75. {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/mask.py +0 -0
  76. {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/mqtt_client_example.py +0 -0
  77. {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/periodic_device_info.py +0 -0
  78. {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/periodic_requests.py +0 -0
  79. {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/power_control_example.py +0 -0
  80. {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/reconnection_demo.py +0 -0
  81. {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/reservation_schedule_example.py +0 -0
  82. {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/set_dhw_temperature_example.py +0 -0
  83. {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/set_mode_example.py +0 -0
  84. {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/simple_auto_recovery.py +0 -0
  85. {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/simple_periodic_info.py +0 -0
  86. {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/simple_periodic_status.py +0 -0
  87. {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/test_api_client.py +0 -0
  88. {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/test_mqtt_connection.py +0 -0
  89. {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/test_mqtt_messaging.py +0 -0
  90. {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/test_periodic_minimal.py +0 -0
  91. {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/tou_openei_example.py +0 -0
  92. {nwp500_python-3.1.4 → nwp500_python-4.7}/examples/tou_schedule_example.py +0 -0
  93. {nwp500_python-3.1.4 → nwp500_python-4.7}/pyproject.toml +0 -0
  94. {nwp500_python-3.1.4 → nwp500_python-4.7}/scripts/format.py +0 -0
  95. {nwp500_python-3.1.4 → nwp500_python-4.7}/scripts/lint.py +0 -0
  96. {nwp500_python-3.1.4 → nwp500_python-4.7}/scripts/setup-dev.py +0 -0
  97. {nwp500_python-3.1.4 → nwp500_python-4.7}/setup.py +0 -0
  98. {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/__init__.py +0 -0
  99. {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/api_client.py +0 -0
  100. {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/cli/__init__.py +0 -0
  101. {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/cli/__main__.py +0 -0
  102. {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/cli/commands.py +0 -0
  103. {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/cli/monitoring.py +0 -0
  104. {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/cli/output_formatters.py +0 -0
  105. {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/cli/token_storage.py +0 -0
  106. {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/cli.py +0 -0
  107. {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/config.py +0 -0
  108. {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/constants.py +0 -0
  109. {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/encoding.py +0 -0
  110. {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/models.py +0 -0
  111. {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/mqtt_command_queue.py +0 -0
  112. {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/mqtt_device_control.py +0 -0
  113. {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/py.typed +0 -0
  114. {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500/utils.py +0 -0
  115. {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500_python.egg-info/SOURCES.txt +0 -0
  116. {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500_python.egg-info/dependency_links.txt +0 -0
  117. {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500_python.egg-info/entry_points.txt +0 -0
  118. {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500_python.egg-info/not-zip-safe +0 -0
  119. {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500_python.egg-info/requires.txt +0 -0
  120. {nwp500_python-3.1.4 → nwp500_python-4.7}/src/nwp500_python.egg-info/top_level.txt +0 -0
  121. {nwp500_python-3.1.4 → nwp500_python-4.7}/tests/conftest.py +0 -0
  122. {nwp500_python-3.1.4 → nwp500_python-4.7}/tests/test_api_helpers.py +0 -0
  123. {nwp500_python-3.1.4 → nwp500_python-4.7}/tests/test_auth.py +0 -0
  124. {nwp500_python-3.1.4 → nwp500_python-4.7}/tests/test_command_queue.py +0 -0
  125. {nwp500_python-3.1.4 → nwp500_python-4.7}/tests/test_events.py +0 -0
  126. {nwp500_python-3.1.4 → nwp500_python-4.7}/tests/test_utils.py +0 -0
  127. {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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nwp500-python
3
- Version: 3.1.4
3
+ Version: 4.7
4
4
  Summary: A library for controlling Navien NWP500 Water Heaters via NaviLink
5
5
  Home-page: https://github.com/eman/nwp500-python
6
6
  Author: Emmanuel Levijarvi
@@ -79,7 +79,7 @@ exclude =
79
79
  docs/conf.py
80
80
 
81
81
  [pyscaffold]
82
- version = 4.6
82
+ version = 4.7
83
83
  package = nwp500
84
84
 
85
85
  [egg_info]
@@ -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 NavienAuthClient
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 Exception as e:
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 Exception as e:
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 Exception as e:
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 Exception as e:
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 Exception as e:
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 Exception as e:
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 Exception as e:
732
+ except AwsCrtError as e:
622
733
  # Handle clean session cancellation gracefully
623
- # Check exception type and name attribute for proper
624
- # error identification
734
+ # Safely check e.name attribute (may not exist or be None)
625
735
  if (
626
- isinstance(e, AwsCrtError)
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
- # Note: redact_topic is already used elsewhere in the file
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 Exception as e:
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 Exception as e:
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 Exception as e:
189
+ except (AwsCrtError, RuntimeError) as e:
190
190
  # Handle clean session cancellation gracefully (expected
191
191
  # during reconnection)
192
- # Check exception type and name attribute for proper error
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 self._reconnect_attempts < self.config.max_reconnect_attempts
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
- _logger.info(
158
- "Reconnection attempt %d/%d in %.1f seconds...",
159
- self._reconnect_attempts,
160
- self.config.max_reconnect_attempts,
161
- delay,
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 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."
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 Exception as e:
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
- self._reconnect_attempts >= self.config.max_reconnect_attempts
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 Exception as e:
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 Exception as e:
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 Exception as e:
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 Exception as e:
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 Exception as e:
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 Exception as e:
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 Exception as e:
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 Exception as e:
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 Exception as e:
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 = 10
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nwp500-python
3
- Version: 3.1.4
3
+ Version: 4.7
4
4
  Summary: A library for controlling Navien NWP500 Water Heaters via NaviLink
5
5
  Home-page: https://github.com/eman/nwp500-python
6
6
  Author: Emmanuel Levijarvi
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