nwp500-python 8.1.2__py3-none-any.whl → 9.0.0__py3-none-any.whl
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/__init__.py +1 -49
- nwp500/_base.py +11 -2
- nwp500/api_client.py +19 -7
- nwp500/auth.py +124 -36
- nwp500/cli/__init__.py +0 -2
- nwp500/cli/__main__.py +7 -5
- nwp500/cli/handlers.py +2 -4
- nwp500/cli/monitoring.py +0 -2
- nwp500/cli/output_formatters.py +4 -19
- nwp500/cli/rich_output.py +2 -3
- nwp500/cli/token_storage.py +7 -4
- nwp500/command_decorators.py +1 -3
- nwp500/config.py +0 -2
- nwp500/converters.py +0 -37
- nwp500/device_capabilities.py +2 -4
- nwp500/device_info_cache.py +0 -2
- nwp500/encoding.py +39 -13
- nwp500/enums.py +0 -2
- nwp500/events.py +22 -22
- nwp500/exceptions.py +0 -59
- nwp500/factory.py +0 -2
- nwp500/field_factory.py +57 -67
- nwp500/models/__init__.py +0 -2
- nwp500/models/_converters.py +0 -2
- nwp500/models/device.py +0 -2
- nwp500/models/energy.py +0 -2
- nwp500/models/feature.py +0 -2
- nwp500/models/mqtt_models.py +0 -2
- nwp500/models/schedule.py +0 -2
- nwp500/models/status.py +6 -4
- nwp500/models/tou.py +0 -2
- nwp500/mqtt/__init__.py +0 -2
- nwp500/mqtt/client.py +77 -46
- nwp500/mqtt/command_queue.py +47 -58
- nwp500/mqtt/connection.py +68 -73
- nwp500/mqtt/control.py +16 -6
- nwp500/mqtt/diagnostics.py +0 -2
- nwp500/mqtt/periodic.py +20 -9
- nwp500/mqtt/reconnection.py +109 -19
- nwp500/mqtt/state_tracker.py +4 -4
- nwp500/mqtt/subscriptions.py +63 -39
- nwp500/mqtt/utils.py +35 -4
- nwp500/mqtt_events.py +12 -14
- nwp500/openei.py +15 -3
- nwp500/reservations.py +0 -2
- nwp500/temperature.py +54 -247
- nwp500/topic_builder.py +0 -2
- nwp500/unit_system.py +24 -23
- nwp500/utils.py +0 -2
- {nwp500_python-8.1.2.dist-info → nwp500_python-9.0.0.dist-info}/METADATA +1 -3
- nwp500_python-9.0.0.dist-info/RECORD +58 -0
- {nwp500_python-8.1.2.dist-info → nwp500_python-9.0.0.dist-info}/WHEEL +1 -1
- nwp500_python-9.0.0.dist-info/scm_file_list.json +203 -0
- nwp500_python-9.0.0.dist-info/scm_version.json +8 -0
- nwp500/cli/commands.py +0 -91
- nwp500_python-8.1.2.dist-info/RECORD +0 -57
- {nwp500_python-8.1.2.dist-info → nwp500_python-9.0.0.dist-info}/entry_points.txt +0 -0
- {nwp500_python-8.1.2.dist-info → nwp500_python-9.0.0.dist-info}/licenses/LICENSE.txt +0 -0
- {nwp500_python-8.1.2.dist-info → nwp500_python-9.0.0.dist-info}/top_level.txt +0 -0
nwp500/__init__.py
CHANGED
|
@@ -4,8 +4,6 @@ This package provides Python bindings for Navien Smart Control API and MQTT
|
|
|
4
4
|
communication for NWP500 heat pump water heaters.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
7
|
from importlib.metadata import (
|
|
10
8
|
PackageNotFoundError,
|
|
11
9
|
version,
|
|
@@ -32,25 +30,6 @@ from nwp500.auth import (
|
|
|
32
30
|
authenticate,
|
|
33
31
|
refresh_access_token,
|
|
34
32
|
)
|
|
35
|
-
from nwp500.command_decorators import (
|
|
36
|
-
requires_capability,
|
|
37
|
-
)
|
|
38
|
-
from nwp500.device_capabilities import (
|
|
39
|
-
MqttDeviceCapabilityChecker,
|
|
40
|
-
)
|
|
41
|
-
from nwp500.device_info_cache import (
|
|
42
|
-
MqttDeviceInfoCache,
|
|
43
|
-
)
|
|
44
|
-
from nwp500.encoding import (
|
|
45
|
-
build_reservation_entry,
|
|
46
|
-
build_tou_period,
|
|
47
|
-
decode_price,
|
|
48
|
-
decode_season_bitfield,
|
|
49
|
-
decode_week_bitfield,
|
|
50
|
-
encode_price,
|
|
51
|
-
encode_season_bitfield,
|
|
52
|
-
encode_week_bitfield,
|
|
53
|
-
)
|
|
54
33
|
from nwp500.enums import (
|
|
55
34
|
CommandCode,
|
|
56
35
|
CurrentOperationMode,
|
|
@@ -79,20 +58,15 @@ from nwp500.exceptions import (
|
|
|
79
58
|
AuthenticationError,
|
|
80
59
|
DeviceCapabilityError,
|
|
81
60
|
DeviceError,
|
|
82
|
-
DeviceNotFoundError,
|
|
83
|
-
DeviceOfflineError,
|
|
84
|
-
DeviceOperationError,
|
|
85
61
|
InvalidCredentialsError,
|
|
86
62
|
MqttConnectionError,
|
|
87
63
|
MqttCredentialsError,
|
|
88
64
|
MqttError,
|
|
89
65
|
MqttNotConnectedError,
|
|
90
66
|
MqttPublishError,
|
|
91
|
-
MqttSubscriptionError,
|
|
92
67
|
Nwp500Error,
|
|
93
68
|
ParameterValidationError,
|
|
94
69
|
RangeValidationError,
|
|
95
|
-
TokenExpiredError,
|
|
96
70
|
TokenRefreshError,
|
|
97
71
|
ValidationError,
|
|
98
72
|
)
|
|
@@ -154,17 +128,11 @@ from nwp500.unit_system import (
|
|
|
154
128
|
reset_unit_system,
|
|
155
129
|
set_unit_system,
|
|
156
130
|
)
|
|
157
|
-
from nwp500.utils import (
|
|
158
|
-
log_performance,
|
|
159
|
-
)
|
|
160
131
|
|
|
161
132
|
__all__ = [
|
|
162
133
|
"__version__",
|
|
163
|
-
# Device Capabilities
|
|
164
|
-
"MqttDeviceCapabilityChecker",
|
|
134
|
+
# Device Capabilities
|
|
165
135
|
"DeviceCapabilityError",
|
|
166
|
-
"MqttDeviceInfoCache",
|
|
167
|
-
"requires_capability",
|
|
168
136
|
# Factory functions
|
|
169
137
|
"create_navien_clients",
|
|
170
138
|
# Models
|
|
@@ -225,22 +193,17 @@ __all__ = [
|
|
|
225
193
|
"Nwp500Error",
|
|
226
194
|
"AuthenticationError",
|
|
227
195
|
"InvalidCredentialsError",
|
|
228
|
-
"TokenExpiredError",
|
|
229
196
|
"TokenRefreshError",
|
|
230
197
|
"APIError",
|
|
231
198
|
"MqttError",
|
|
232
199
|
"MqttConnectionError",
|
|
233
200
|
"MqttNotConnectedError",
|
|
234
201
|
"MqttPublishError",
|
|
235
|
-
"MqttSubscriptionError",
|
|
236
202
|
"MqttCredentialsError",
|
|
237
203
|
"ValidationError",
|
|
238
204
|
"ParameterValidationError",
|
|
239
205
|
"RangeValidationError",
|
|
240
206
|
"DeviceError",
|
|
241
|
-
"DeviceNotFoundError",
|
|
242
|
-
"DeviceOfflineError",
|
|
243
|
-
"DeviceOperationError",
|
|
244
207
|
# API Client
|
|
245
208
|
"NavienAPIClient",
|
|
246
209
|
# OpenEI Client
|
|
@@ -262,17 +225,6 @@ __all__ = [
|
|
|
262
225
|
"EventEmitter",
|
|
263
226
|
"EventListener",
|
|
264
227
|
"MqttClientEvents",
|
|
265
|
-
# Encoding utilities
|
|
266
|
-
"encode_week_bitfield",
|
|
267
|
-
"decode_week_bitfield",
|
|
268
|
-
"encode_season_bitfield",
|
|
269
|
-
"decode_season_bitfield",
|
|
270
|
-
"encode_price",
|
|
271
|
-
"decode_price",
|
|
272
|
-
"build_reservation_entry",
|
|
273
|
-
"build_tou_period",
|
|
274
|
-
# Utilities
|
|
275
|
-
"log_performance",
|
|
276
228
|
# Unit system management
|
|
277
229
|
"set_unit_system",
|
|
278
230
|
"get_unit_system",
|
nwp500/_base.py
CHANGED
|
@@ -5,8 +5,6 @@ enum serialization) so that both the authentication models and the device
|
|
|
5
5
|
protocol models share a single base class.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
8
|
from typing import Any
|
|
11
9
|
|
|
12
10
|
from pydantic import BaseModel, ConfigDict
|
|
@@ -39,6 +37,17 @@ class NavienBaseModel(BaseModel):
|
|
|
39
37
|
converted: dict[str, Any] = self._convert_enums_to_names(result)
|
|
40
38
|
return converted
|
|
41
39
|
|
|
40
|
+
def to_protocol_dict(self) -> dict[str, Any]:
|
|
41
|
+
"""Dump only the declared protocol fields, by alias.
|
|
42
|
+
|
|
43
|
+
Excludes pydantic ``computed_field`` properties, which are
|
|
44
|
+
display-oriented (formatted times, weekday names, unit-converted
|
|
45
|
+
temperatures) and must never be sent to the device.
|
|
46
|
+
"""
|
|
47
|
+
return self.model_dump(
|
|
48
|
+
by_alias=True, include=set(type(self).model_fields)
|
|
49
|
+
)
|
|
50
|
+
|
|
42
51
|
@staticmethod
|
|
43
52
|
def _convert_enums_to_names(
|
|
44
53
|
data: Any, visited: set[int] | None = None
|
nwp500/api_client.py
CHANGED
|
@@ -4,10 +4,8 @@ Client for interacting with the Navien NWP500 API.
|
|
|
4
4
|
This module provides an async HTTP client for device management and control.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
7
|
import logging
|
|
10
|
-
from typing import Any, Self
|
|
8
|
+
from typing import Any, Self
|
|
11
9
|
|
|
12
10
|
import aiohttp
|
|
13
11
|
|
|
@@ -79,9 +77,13 @@ class NavienAPIClient:
|
|
|
79
77
|
self.base_url = base_url.rstrip("/")
|
|
80
78
|
self._auth_client = auth_client
|
|
81
79
|
self._unit_system = unit_system
|
|
82
|
-
|
|
80
|
+
# An explicitly provided session always wins; otherwise the auth
|
|
81
|
+
# client's session is resolved per request (never cached) so the
|
|
82
|
+
# API client keeps working if the auth client recreates its
|
|
83
|
+
# session.
|
|
84
|
+
self._session_override = session
|
|
83
85
|
|
|
84
|
-
if self.
|
|
86
|
+
if self._session_override is None and auth_client.session is None:
|
|
85
87
|
raise ValueError(
|
|
86
88
|
"auth_client must have an active session or a session "
|
|
87
89
|
"must be provided"
|
|
@@ -89,6 +91,11 @@ class NavienAPIClient:
|
|
|
89
91
|
self._owned_session = False
|
|
90
92
|
self._owned_auth = False
|
|
91
93
|
|
|
94
|
+
@property
|
|
95
|
+
def _session(self) -> aiohttp.ClientSession | None:
|
|
96
|
+
"""The session used for requests, resolved dynamically."""
|
|
97
|
+
return self._session_override or self._auth_client.session
|
|
98
|
+
|
|
92
99
|
async def __aenter__(self) -> Self:
|
|
93
100
|
"""Enter async context manager."""
|
|
94
101
|
return self
|
|
@@ -152,7 +159,12 @@ class NavienAPIClient:
|
|
|
152
159
|
|
|
153
160
|
try:
|
|
154
161
|
_logger.debug(f"Starting {method} request to {url}")
|
|
155
|
-
session =
|
|
162
|
+
session = self._session
|
|
163
|
+
if session is None:
|
|
164
|
+
raise APIError(
|
|
165
|
+
"No active session available. The auth client session "
|
|
166
|
+
"has been closed."
|
|
167
|
+
)
|
|
156
168
|
async with session.request(
|
|
157
169
|
method,
|
|
158
170
|
url,
|
|
@@ -217,7 +229,7 @@ class NavienAPIClient:
|
|
|
217
229
|
|
|
218
230
|
except aiohttp.ClientError as e:
|
|
219
231
|
_logger.error(f"Network error: {e}")
|
|
220
|
-
raise APIError(f"Network error: {
|
|
232
|
+
raise APIError(f"Network error: {e!s}") from e
|
|
221
233
|
|
|
222
234
|
# Device Management Endpoints
|
|
223
235
|
|
nwp500/auth.py
CHANGED
|
@@ -11,11 +11,12 @@ The API uses JWT (JSON Web Tokens) for authentication with the following flow:
|
|
|
11
11
|
4. Refresh tokens when accessToken expires
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
import asyncio
|
|
16
15
|
import json
|
|
17
16
|
import logging
|
|
18
17
|
from datetime import UTC, datetime, timedelta
|
|
18
|
+
from importlib.metadata import PackageNotFoundError
|
|
19
|
+
from importlib.metadata import version as _dist_version
|
|
19
20
|
from typing import Any, Self, cast
|
|
20
21
|
|
|
21
22
|
import aiohttp
|
|
@@ -26,7 +27,6 @@ from pydantic import (
|
|
|
26
27
|
model_validator,
|
|
27
28
|
)
|
|
28
29
|
|
|
29
|
-
from . import __version__
|
|
30
30
|
from ._base import NavienBaseModel
|
|
31
31
|
from .config import API_BASE_URL, REFRESH_ENDPOINT, SIGN_IN_ENDPOINT
|
|
32
32
|
from .exceptions import (
|
|
@@ -36,6 +36,15 @@ from .exceptions import (
|
|
|
36
36
|
)
|
|
37
37
|
from .unit_system import UnitSystemType
|
|
38
38
|
|
|
39
|
+
# Resolve the package version directly from distribution metadata instead
|
|
40
|
+
# of importing it from the package __init__ (which imports this module,
|
|
41
|
+
# making `from . import __version__` an order-dependent near-circular
|
|
42
|
+
# import).
|
|
43
|
+
try:
|
|
44
|
+
__version__ = _dist_version("nwp500-python")
|
|
45
|
+
except PackageNotFoundError: # pragma: no cover
|
|
46
|
+
__version__ = "unknown"
|
|
47
|
+
|
|
39
48
|
__author__ = "Emmanuel Levijarvi"
|
|
40
49
|
__copyright__ = "Emmanuel Levijarvi"
|
|
41
50
|
__license__ = "MIT"
|
|
@@ -328,6 +337,12 @@ class NavienAuthClient:
|
|
|
328
337
|
self._auth_response: AuthenticationResponse | None = None
|
|
329
338
|
self._user_email: str | None = None
|
|
330
339
|
|
|
340
|
+
# Serializes token refresh / re-authentication so concurrent
|
|
341
|
+
# callers (API 401 retry, MQTT reconnect, periodic requests)
|
|
342
|
+
# cannot stampede the refresh endpoint and invalidate each
|
|
343
|
+
# other's tokens.
|
|
344
|
+
self._refresh_lock = asyncio.Lock()
|
|
345
|
+
|
|
331
346
|
# Restore tokens if provided
|
|
332
347
|
if stored_tokens:
|
|
333
348
|
# Create a minimal AuthenticationResponse with stored tokens
|
|
@@ -339,27 +354,53 @@ class NavienAuthClient:
|
|
|
339
354
|
self._user_email = user_id
|
|
340
355
|
|
|
341
356
|
async def __aenter__(self) -> Self:
|
|
342
|
-
"""Async context manager entry.
|
|
343
|
-
|
|
357
|
+
"""Async context manager entry.
|
|
358
|
+
|
|
359
|
+
Idempotent with respect to session creation: entering an
|
|
360
|
+
already-entered client reuses the existing session instead of
|
|
361
|
+
creating (and orphaning) a new one.
|
|
362
|
+
|
|
363
|
+
If authentication fails, the owned session is closed before the
|
|
364
|
+
exception propagates, because ``__aexit__`` is never called when
|
|
365
|
+
``__aenter__`` raises.
|
|
366
|
+
"""
|
|
367
|
+
if self._owned_session and self._session is None:
|
|
344
368
|
self._session = self._create_session()
|
|
345
369
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
370
|
+
try:
|
|
371
|
+
# Check if we have valid stored tokens
|
|
372
|
+
if self._auth_response and self._auth_response.tokens:
|
|
373
|
+
tokens = self._auth_response.tokens
|
|
374
|
+
# If tokens are expired, refresh or re-authenticate
|
|
375
|
+
if tokens.are_aws_credentials_expired:
|
|
376
|
+
_logger.info(
|
|
377
|
+
"Stored AWS credentials expired, re-authenticating..."
|
|
378
|
+
)
|
|
379
|
+
await self.sign_in(self._user_id, self._password)
|
|
380
|
+
elif tokens.is_expired:
|
|
381
|
+
_logger.info("Stored JWT token expired, refreshing...")
|
|
382
|
+
try:
|
|
383
|
+
await self.refresh_token(tokens.refresh_token)
|
|
384
|
+
except TokenRefreshError as e:
|
|
385
|
+
if not self.has_stored_credentials:
|
|
386
|
+
raise
|
|
387
|
+
_logger.warning(
|
|
388
|
+
"Stored token refresh failed: %s. Falling back "
|
|
389
|
+
"to full sign-in...",
|
|
390
|
+
e,
|
|
391
|
+
)
|
|
392
|
+
await self.sign_in(self._user_id, self._password)
|
|
393
|
+
else:
|
|
394
|
+
_logger.info("Using stored tokens, skipping authentication")
|
|
358
395
|
else:
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
396
|
+
# No stored tokens, perform full authentication
|
|
397
|
+
await self.sign_in(self._user_id, self._password)
|
|
398
|
+
except BaseException:
|
|
399
|
+
# __aexit__ won't run; don't leak the owned session.
|
|
400
|
+
if self._owned_session and self._session:
|
|
401
|
+
await self._session.close()
|
|
402
|
+
self._session = None
|
|
403
|
+
raise
|
|
363
404
|
|
|
364
405
|
return self
|
|
365
406
|
|
|
@@ -367,6 +408,7 @@ class NavienAuthClient:
|
|
|
367
408
|
"""Async context manager exit."""
|
|
368
409
|
if self._owned_session and self._session:
|
|
369
410
|
await self._session.close()
|
|
411
|
+
self._session = None
|
|
370
412
|
|
|
371
413
|
def _create_session(self) -> aiohttp.ClientSession:
|
|
372
414
|
"""Create an aiohttp ClientSession with ThreadedResolver.
|
|
@@ -462,14 +504,12 @@ class NavienAuthClient:
|
|
|
462
504
|
except aiohttp.ClientError as e:
|
|
463
505
|
_logger.error(f"Network error during sign-in: {e}")
|
|
464
506
|
raise AuthenticationError(
|
|
465
|
-
f"Network error: {
|
|
507
|
+
f"Network error: {e!s}",
|
|
466
508
|
retriable=True,
|
|
467
509
|
) from e
|
|
468
510
|
except (KeyError, ValueError, json.JSONDecodeError) as e:
|
|
469
511
|
_logger.error(f"Failed to parse authentication response: {e}")
|
|
470
|
-
raise AuthenticationError(
|
|
471
|
-
f"Invalid response format: {str(e)}"
|
|
472
|
-
) from e
|
|
512
|
+
raise AuthenticationError(f"Invalid response format: {e!s}") from e
|
|
473
513
|
|
|
474
514
|
async def refresh_token(
|
|
475
515
|
self, refresh_token: str | None = None
|
|
@@ -477,6 +517,11 @@ class NavienAuthClient:
|
|
|
477
517
|
"""
|
|
478
518
|
Refresh access token using refresh token.
|
|
479
519
|
|
|
520
|
+
Refreshes are serialized: if another task is already refreshing,
|
|
521
|
+
this call waits for it and — when no explicit ``refresh_token``
|
|
522
|
+
was given and the stored tokens are now valid — returns the
|
|
523
|
+
already-refreshed tokens instead of issuing a duplicate request.
|
|
524
|
+
|
|
480
525
|
Args:
|
|
481
526
|
refresh_token: The refresh token obtained from sign-in.
|
|
482
527
|
If not provided, uses the stored refresh token.
|
|
@@ -487,6 +532,25 @@ class NavienAuthClient:
|
|
|
487
532
|
Raises:
|
|
488
533
|
TokenRefreshError: If token refresh fails or no token available
|
|
489
534
|
"""
|
|
535
|
+
async with self._refresh_lock:
|
|
536
|
+
current = self.current_tokens
|
|
537
|
+
# Another task may have refreshed while we waited for the
|
|
538
|
+
# lock; don't hit the endpoint again with valid tokens. An
|
|
539
|
+
# explicit refresh_token equal to the stored one is a forced
|
|
540
|
+
# refresh and proceeds; a differing one is stale (rotated by
|
|
541
|
+
# the concurrent refresh) and would be rejected anyway.
|
|
542
|
+
if (
|
|
543
|
+
current
|
|
544
|
+
and not current.is_expired
|
|
545
|
+
and refresh_token != current.refresh_token
|
|
546
|
+
):
|
|
547
|
+
return current
|
|
548
|
+
return await self._refresh_token_unlocked(refresh_token)
|
|
549
|
+
|
|
550
|
+
async def _refresh_token_unlocked(
|
|
551
|
+
self, refresh_token: str | None = None
|
|
552
|
+
) -> AuthTokens:
|
|
553
|
+
"""Perform the actual token refresh. Caller must hold _refresh_lock."""
|
|
490
554
|
if refresh_token is None:
|
|
491
555
|
if self._auth_response and self._auth_response.tokens.refresh_token:
|
|
492
556
|
refresh_token = self._auth_response.tokens.refresh_token
|
|
@@ -521,10 +585,20 @@ class NavienAuthClient:
|
|
|
521
585
|
data = response_data.get("data", {})
|
|
522
586
|
new_tokens = AuthTokens.model_validate(data)
|
|
523
587
|
|
|
524
|
-
# Preserve
|
|
525
|
-
#
|
|
588
|
+
# Preserve fields the refresh response may omit. The
|
|
589
|
+
# refresh endpoint does not return AWS credentials, and it
|
|
590
|
+
# may not echo the refresh token itself — without this,
|
|
591
|
+
# new_tokens.refresh_token would be "" and every
|
|
592
|
+
# subsequent refresh would fail.
|
|
526
593
|
if self._auth_response and self._auth_response.tokens:
|
|
527
594
|
old_tokens = self._auth_response.tokens
|
|
595
|
+
if (
|
|
596
|
+
not new_tokens.refresh_token
|
|
597
|
+
and old_tokens.refresh_token
|
|
598
|
+
):
|
|
599
|
+
new_tokens.refresh_token = old_tokens.refresh_token
|
|
600
|
+
if not new_tokens.id_token and old_tokens.id_token:
|
|
601
|
+
new_tokens.id_token = old_tokens.id_token
|
|
528
602
|
if (
|
|
529
603
|
not new_tokens.access_key_id
|
|
530
604
|
and old_tokens.access_key_id
|
|
@@ -563,12 +637,12 @@ class NavienAuthClient:
|
|
|
563
637
|
except aiohttp.ClientError as e:
|
|
564
638
|
_logger.error(f"Network error during token refresh: {e}")
|
|
565
639
|
raise TokenRefreshError(
|
|
566
|
-
f"Network error: {
|
|
640
|
+
f"Network error: {e!s}",
|
|
567
641
|
retriable=True,
|
|
568
642
|
) from e
|
|
569
643
|
except (KeyError, ValueError, json.JSONDecodeError) as e:
|
|
570
644
|
_logger.error(f"Failed to parse refresh response: {e}")
|
|
571
|
-
raise TokenRefreshError(f"Invalid response format: {
|
|
645
|
+
raise TokenRefreshError(f"Invalid response format: {e!s}") from e
|
|
572
646
|
|
|
573
647
|
async def re_authenticate(self) -> AuthenticationResponse:
|
|
574
648
|
"""
|
|
@@ -621,15 +695,28 @@ class NavienAuthClient:
|
|
|
621
695
|
|
|
622
696
|
# Check if AWS credentials have expired
|
|
623
697
|
if tokens.are_aws_credentials_expired:
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
698
|
+
async with self._refresh_lock:
|
|
699
|
+
# Re-check after acquiring the lock: another task may have
|
|
700
|
+
# re-authenticated while we waited.
|
|
701
|
+
if not self._auth_response:
|
|
702
|
+
return None
|
|
703
|
+
tokens = self._auth_response.tokens
|
|
704
|
+
if tokens.are_aws_credentials_expired:
|
|
705
|
+
_logger.info(
|
|
706
|
+
"AWS credentials expired, re-authenticating..."
|
|
707
|
+
)
|
|
708
|
+
# Re-authenticate to get fresh AWS credentials
|
|
709
|
+
await self.sign_in(self._user_id, self._password)
|
|
710
|
+
return (
|
|
711
|
+
self._auth_response.tokens if self._auth_response else None
|
|
712
|
+
)
|
|
628
713
|
|
|
629
|
-
# Check if JWT token has expired
|
|
714
|
+
# Check if JWT token has expired. refresh_token() serializes
|
|
715
|
+
# concurrent refreshes and returns the already-refreshed tokens
|
|
716
|
+
# to callers that lost the race.
|
|
630
717
|
if tokens.is_expired:
|
|
631
718
|
_logger.info("Token expired, refreshing...")
|
|
632
|
-
return await self.refresh_token(
|
|
719
|
+
return await self.refresh_token()
|
|
633
720
|
|
|
634
721
|
return tokens
|
|
635
722
|
|
|
@@ -779,8 +866,9 @@ async def refresh_access_token(refresh_token: str) -> AuthTokens:
|
|
|
779
866
|
# Use ThreadedResolver for reliable DNS in containerized environments
|
|
780
867
|
resolver = aiohttp.ThreadedResolver()
|
|
781
868
|
connector = aiohttp.TCPConnector(resolver=resolver)
|
|
869
|
+
timeout = aiohttp.ClientTimeout(total=30)
|
|
782
870
|
async with (
|
|
783
|
-
aiohttp.ClientSession(connector=connector) as session,
|
|
871
|
+
aiohttp.ClientSession(connector=connector, timeout=timeout) as session,
|
|
784
872
|
session.post(url, json=payload) as response,
|
|
785
873
|
):
|
|
786
874
|
response_data = await response.json()
|
nwp500/cli/__init__.py
CHANGED
nwp500/cli/__main__.py
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
"""Navien Water Heater Control CLI - Main Entry Point."""
|
|
2
2
|
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
3
|
import asyncio
|
|
6
4
|
import functools
|
|
7
5
|
import logging
|
|
@@ -88,6 +86,10 @@ def async_command(f: Any) -> Any:
|
|
|
88
86
|
|
|
89
87
|
# Load cached tokens if available
|
|
90
88
|
tokens, cached_email = load_tokens()
|
|
89
|
+
# Cached tokens belong to cached_email; never reuse them for a
|
|
90
|
+
# different account.
|
|
91
|
+
if email and cached_email and email != cached_email:
|
|
92
|
+
tokens = None
|
|
91
93
|
# If email not provided in args, try cached email
|
|
92
94
|
email = email or cached_email
|
|
93
95
|
|
|
@@ -162,7 +164,9 @@ def async_command(f: Any) -> Any:
|
|
|
162
164
|
_formatter.print_error(str(e), title="Unexpected Error")
|
|
163
165
|
return 1
|
|
164
166
|
|
|
165
|
-
return
|
|
167
|
+
# click ignores callback return values in standalone mode, so
|
|
168
|
+
# propagate failures via ctx.exit to get a non-zero exit code.
|
|
169
|
+
ctx.exit(asyncio.run(runner()))
|
|
166
170
|
|
|
167
171
|
return wrapper
|
|
168
172
|
|
|
@@ -288,12 +292,10 @@ async def power(mqtt: NavienMqttClient, device: Any, state: str) -> None:
|
|
|
288
292
|
"mode_name",
|
|
289
293
|
type=click.Choice(
|
|
290
294
|
[
|
|
291
|
-
"standby",
|
|
292
295
|
"heat-pump",
|
|
293
296
|
"electric",
|
|
294
297
|
"energy-saver",
|
|
295
298
|
"high-demand",
|
|
296
|
-
"vacation",
|
|
297
299
|
],
|
|
298
300
|
case_sensitive=False,
|
|
299
301
|
),
|
nwp500/cli/handlers.py
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
"""Command handlers for CLI operations."""
|
|
2
2
|
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
3
|
import asyncio
|
|
6
4
|
import json
|
|
7
5
|
import logging
|
|
@@ -220,13 +218,13 @@ async def handle_set_mode_request(
|
|
|
220
218
|
mqtt: NavienMqttClient, device: Device, mode_name: str
|
|
221
219
|
) -> None:
|
|
222
220
|
"""Set device operation mode."""
|
|
221
|
+
# Vacation mode (5) requires a day count and has its own `vacation`
|
|
222
|
+
# command; power-off (6) is handled by the `power` command.
|
|
223
223
|
mode_mapping = {
|
|
224
|
-
"standby": 0,
|
|
225
224
|
"heat-pump": 1,
|
|
226
225
|
"electric": 2,
|
|
227
226
|
"energy-saver": 3,
|
|
228
227
|
"high-demand": 4,
|
|
229
|
-
"vacation": 5,
|
|
230
228
|
}
|
|
231
229
|
mode_id = mode_mapping.get(mode_name.lower())
|
|
232
230
|
if mode_id is None:
|
nwp500/cli/monitoring.py
CHANGED
nwp500/cli/output_formatters.py
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
"""Output formatting utilities for CLI (CSV, JSON)."""
|
|
2
2
|
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
3
|
import csv
|
|
6
4
|
import json
|
|
7
5
|
import logging
|
|
@@ -369,13 +367,14 @@ def write_status_to_csv(file_path: str, status: DeviceStatus) -> None:
|
|
|
369
367
|
# Convert status to dict (enums are already converted to names)
|
|
370
368
|
status_dict = status.model_dump()
|
|
371
369
|
|
|
372
|
-
# Add a timestamp to the beginning of the data
|
|
373
|
-
|
|
370
|
+
# Add a timestamp to the beginning of the data (timezone-aware,
|
|
371
|
+
# in the local timezone)
|
|
372
|
+
status_dict["timestamp"] = datetime.now().astimezone().isoformat()
|
|
374
373
|
|
|
375
374
|
# Check if file exists to determine if we need to write the header
|
|
376
375
|
file_exists = Path(file_path).exists()
|
|
377
376
|
|
|
378
|
-
with open(
|
|
377
|
+
with Path(file_path).open("a", newline="") as csvfile:
|
|
379
378
|
# Get the field names from the dict keys
|
|
380
379
|
fieldnames = list(status_dict.keys())
|
|
381
380
|
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
|
|
@@ -897,13 +896,6 @@ def print_device_status(device_status: Any) -> None:
|
|
|
897
896
|
)
|
|
898
897
|
)
|
|
899
898
|
|
|
900
|
-
# Calculate widths dynamically
|
|
901
|
-
max_label_len = max((len(label) for _, label, _ in all_items), default=20)
|
|
902
|
-
max_value_len = max(
|
|
903
|
-
(len(str(value)) for _, _, value in all_items), default=20
|
|
904
|
-
)
|
|
905
|
-
_line_width = max_label_len + max_value_len + 4 # +4 for padding
|
|
906
|
-
|
|
907
899
|
# Use rich formatter for output
|
|
908
900
|
formatter = get_formatter()
|
|
909
901
|
formatter.print_status_table(all_items)
|
|
@@ -1109,13 +1101,6 @@ def print_device_info(device_feature: Any) -> None:
|
|
|
1109
1101
|
status = "Yes" if value else "No"
|
|
1110
1102
|
all_items.append(("SUPPORTED FEATURES", label, status))
|
|
1111
1103
|
|
|
1112
|
-
# Calculate widths dynamically
|
|
1113
|
-
max_label_len = max((len(label) for _, label, _ in all_items), default=20)
|
|
1114
|
-
max_value_len = max(
|
|
1115
|
-
(len(str(value)) for _, _, value in all_items), default=20
|
|
1116
|
-
)
|
|
1117
|
-
_line_width = max_label_len + max_value_len + 4 # +4 for padding
|
|
1118
|
-
|
|
1119
1104
|
# Use rich formatter for output
|
|
1120
1105
|
formatter = get_formatter()
|
|
1121
1106
|
formatter.print_status_table(all_items)
|
nwp500/cli/rich_output.py
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
"""Rich-enhanced output formatting with graceful fallback."""
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
import itertools
|
|
5
4
|
import json
|
|
6
5
|
import logging
|
|
7
6
|
import os
|
|
@@ -142,7 +141,7 @@ def _collapse_ranges(
|
|
|
142
141
|
|
|
143
142
|
# Build groups of consecutive items
|
|
144
143
|
groups: list[list[Any]] = [[items[0]]]
|
|
145
|
-
for prev, curr in
|
|
144
|
+
for prev, curr in itertools.pairwise(items):
|
|
146
145
|
if isinstance(prev, int):
|
|
147
146
|
consecutive = (curr - prev) == 1 or (
|
|
148
147
|
prev == cycle_size and curr == 1
|
nwp500/cli/token_storage.py
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
"""Token storage and management for CLI authentication."""
|
|
2
2
|
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
3
|
import json
|
|
6
4
|
import logging
|
|
5
|
+
import os
|
|
7
6
|
from pathlib import Path
|
|
8
7
|
|
|
9
8
|
from nwp500.auth import AuthTokens
|
|
@@ -22,7 +21,11 @@ def save_tokens(tokens: AuthTokens, email: str) -> None:
|
|
|
22
21
|
email: User email address
|
|
23
22
|
"""
|
|
24
23
|
try:
|
|
25
|
-
|
|
24
|
+
# Tokens grant account access; keep the file owner-readable only.
|
|
25
|
+
# O_CREAT mode only applies to new files, so chmod existing ones.
|
|
26
|
+
fd = os.open(TOKEN_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
27
|
+
with os.fdopen(fd, "w") as f:
|
|
28
|
+
TOKEN_FILE.chmod(0o600)
|
|
26
29
|
# Use the built-in to_dict() method for serialization
|
|
27
30
|
token_data = tokens.to_dict()
|
|
28
31
|
token_data["email"] = email
|
|
@@ -42,7 +45,7 @@ def load_tokens() -> tuple[AuthTokens | None, str | None]:
|
|
|
42
45
|
if not TOKEN_FILE.exists():
|
|
43
46
|
return None, None
|
|
44
47
|
try:
|
|
45
|
-
with open(
|
|
48
|
+
with TOKEN_FILE.open() as f:
|
|
46
49
|
data = json.load(f)
|
|
47
50
|
email = data.get("email")
|
|
48
51
|
if not email:
|
nwp500/command_decorators.py
CHANGED
|
@@ -4,8 +4,6 @@ This module provides decorators that automatically validate device capabilities
|
|
|
4
4
|
before command execution, preventing unsupported commands from being sent.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
7
|
import functools
|
|
10
8
|
import inspect
|
|
11
9
|
import logging
|
|
@@ -84,7 +82,7 @@ def requires_capability(feature: str) -> Callable[[F], F]:
|
|
|
84
82
|
# Wrap other errors (timeouts, connection issues, etc)
|
|
85
83
|
raise DeviceCapabilityError(
|
|
86
84
|
feature,
|
|
87
|
-
f"Cannot execute {func.__name__}: {
|
|
85
|
+
f"Cannot execute {func.__name__}: {e!s}",
|
|
88
86
|
) from e
|
|
89
87
|
|
|
90
88
|
if cached_features is None:
|