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.
Files changed (126) hide show
  1. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/.github/copilot-instructions.md +2 -0
  2. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/CHANGELOG.rst +36 -0
  3. {nwp500_python-3.1.1/src/nwp500_python.egg-info → nwp500_python-3.1.3}/PKG-INFO +1 -1
  4. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/api_client.py +43 -1
  5. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/auth.py +24 -0
  6. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/cli/__main__.py +4 -0
  7. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/mqtt_client.py +68 -0
  8. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/mqtt_reconnection.py +45 -7
  9. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/mqtt_subscriptions.py +18 -0
  10. {nwp500_python-3.1.1 → nwp500_python-3.1.3/src/nwp500_python.egg-info}/PKG-INFO +1 -1
  11. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/.coveragerc +0 -0
  12. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/.github/workflows/ci.yml +0 -0
  13. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/.github/workflows/release.yml +0 -0
  14. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/.gitignore +0 -0
  15. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/.pre-commit-config.yaml +0 -0
  16. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/.readthedocs.yml +0 -0
  17. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/AUTHORS.rst +0 -0
  18. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/CONTRIBUTING.rst +0 -0
  19. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/LICENSE.txt +0 -0
  20. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/Makefile +0 -0
  21. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/README.rst +0 -0
  22. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/RELEASE.md +0 -0
  23. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/Makefile +0 -0
  24. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/_static/.gitignore +0 -0
  25. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/authors.rst +0 -0
  26. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/changelog.rst +0 -0
  27. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/conf.py +0 -0
  28. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/configuration.rst +0 -0
  29. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/development/contributing.rst +0 -0
  30. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/development/history.rst +0 -0
  31. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/guides/auto_recovery.rst +0 -0
  32. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/guides/command_queue.rst +0 -0
  33. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/guides/energy_monitoring.rst +0 -0
  34. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/guides/event_system.rst +0 -0
  35. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/guides/reservations.rst +0 -0
  36. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/guides/time_of_use.rst +0 -0
  37. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/index.rst +0 -0
  38. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/installation.rst +0 -0
  39. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/license.rst +0 -0
  40. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/openapi.yaml +0 -0
  41. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/protocol/device_features.rst +0 -0
  42. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/protocol/device_status.rst +0 -0
  43. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/protocol/error_codes.rst +0 -0
  44. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/protocol/firmware_tracking.rst +0 -0
  45. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/protocol/mqtt_protocol.rst +0 -0
  46. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/protocol/rest_api.rst +0 -0
  47. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/python_api/api_client.rst +0 -0
  48. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/python_api/auth_client.rst +0 -0
  49. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/python_api/cli.rst +0 -0
  50. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/python_api/constants.rst +0 -0
  51. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/python_api/events.rst +0 -0
  52. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/python_api/exceptions.rst +0 -0
  53. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/python_api/models.rst +0 -0
  54. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/python_api/mqtt_client.rst +0 -0
  55. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/quickstart.rst +0 -0
  56. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/docs/requirements.txt +0 -0
  57. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/.ruff.toml +0 -0
  58. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/README.md +0 -0
  59. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/anti_legionella_example.py +0 -0
  60. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/api_client_example.py +0 -0
  61. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/auth_constructor_example.py +0 -0
  62. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/authenticate.py +0 -0
  63. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/auto_recovery_example.py +0 -0
  64. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/combined_callbacks.py +0 -0
  65. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/command_queue_demo.py +0 -0
  66. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/device_feature_callback.py +0 -0
  67. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/device_status_callback.py +0 -0
  68. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/device_status_callback_debug.py +0 -0
  69. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/energy_usage_example.py +0 -0
  70. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/event_emitter_demo.py +0 -0
  71. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/improved_auth_pattern.py +0 -0
  72. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/mask.py +0 -0
  73. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/mqtt_client_example.py +0 -0
  74. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/periodic_device_info.py +0 -0
  75. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/periodic_requests.py +0 -0
  76. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/power_control_example.py +0 -0
  77. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/reconnection_demo.py +0 -0
  78. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/reservation_schedule_example.py +0 -0
  79. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/set_dhw_temperature_example.py +0 -0
  80. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/set_mode_example.py +0 -0
  81. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/simple_auto_recovery.py +0 -0
  82. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/simple_periodic_info.py +0 -0
  83. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/simple_periodic_status.py +0 -0
  84. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/test_api_client.py +0 -0
  85. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/test_mqtt_connection.py +0 -0
  86. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/test_mqtt_messaging.py +0 -0
  87. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/test_periodic_minimal.py +0 -0
  88. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/tou_openei_example.py +0 -0
  89. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/examples/tou_schedule_example.py +0 -0
  90. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/pyproject.toml +0 -0
  91. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/scripts/format.py +0 -0
  92. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/scripts/lint.py +0 -0
  93. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/scripts/setup-dev.py +0 -0
  94. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/setup.cfg +0 -0
  95. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/setup.py +0 -0
  96. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/__init__.py +0 -0
  97. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/cli/__init__.py +0 -0
  98. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/cli/commands.py +0 -0
  99. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/cli/monitoring.py +0 -0
  100. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/cli/output_formatters.py +0 -0
  101. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/cli/token_storage.py +0 -0
  102. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/cli.py +0 -0
  103. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/config.py +0 -0
  104. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/constants.py +0 -0
  105. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/encoding.py +0 -0
  106. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/events.py +0 -0
  107. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/models.py +0 -0
  108. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/mqtt_command_queue.py +0 -0
  109. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/mqtt_connection.py +0 -0
  110. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/mqtt_device_control.py +0 -0
  111. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/mqtt_periodic.py +0 -0
  112. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/mqtt_utils.py +0 -0
  113. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/py.typed +0 -0
  114. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500/utils.py +0 -0
  115. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500_python.egg-info/SOURCES.txt +0 -0
  116. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500_python.egg-info/dependency_links.txt +0 -0
  117. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500_python.egg-info/entry_points.txt +0 -0
  118. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500_python.egg-info/not-zip-safe +0 -0
  119. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500_python.egg-info/requires.txt +0 -0
  120. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/src/nwp500_python.egg-info/top_level.txt +0 -0
  121. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/tests/conftest.py +0 -0
  122. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/tests/test_api_helpers.py +0 -0
  123. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/tests/test_command_queue.py +0 -0
  124. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/tests/test_events.py +0 -0
  125. {nwp500_python-3.1.1 → nwp500_python-3.1.3}/tests/test_utils.py +0 -0
  126. {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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nwp500-python
3
- Version: 3.1.1
3
+ Version: 3.1.3
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
@@ -9,7 +9,11 @@ from typing import Any, Optional
9
9
 
10
10
  import aiohttp
11
11
 
12
- from .auth import AuthenticationError, NavienAuthClient
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
- # AWS IoT SDK will handle the actual reconnection automatically
160
- # We just need to wait and monitor the connection state
161
- _logger.debug(
162
- "Waiting for AWS IoT SDK automatic reconnection..."
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(f"Error during reconnection attempt: {e}")
191
+ _logger.error(
192
+ f"Error during reconnection attempt: {e}", exc_info=True
193
+ )
170
194
 
171
- if self._reconnect_attempts >= self.config.max_reconnect_attempts:
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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nwp500-python
3
- Version: 3.1.1
3
+ Version: 3.1.3
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