nwp500-python 3.1.2__tar.gz → 3.1.4__tar.gz

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