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.
Files changed (59) hide show
  1. nwp500/__init__.py +1 -49
  2. nwp500/_base.py +11 -2
  3. nwp500/api_client.py +19 -7
  4. nwp500/auth.py +124 -36
  5. nwp500/cli/__init__.py +0 -2
  6. nwp500/cli/__main__.py +7 -5
  7. nwp500/cli/handlers.py +2 -4
  8. nwp500/cli/monitoring.py +0 -2
  9. nwp500/cli/output_formatters.py +4 -19
  10. nwp500/cli/rich_output.py +2 -3
  11. nwp500/cli/token_storage.py +7 -4
  12. nwp500/command_decorators.py +1 -3
  13. nwp500/config.py +0 -2
  14. nwp500/converters.py +0 -37
  15. nwp500/device_capabilities.py +2 -4
  16. nwp500/device_info_cache.py +0 -2
  17. nwp500/encoding.py +39 -13
  18. nwp500/enums.py +0 -2
  19. nwp500/events.py +22 -22
  20. nwp500/exceptions.py +0 -59
  21. nwp500/factory.py +0 -2
  22. nwp500/field_factory.py +57 -67
  23. nwp500/models/__init__.py +0 -2
  24. nwp500/models/_converters.py +0 -2
  25. nwp500/models/device.py +0 -2
  26. nwp500/models/energy.py +0 -2
  27. nwp500/models/feature.py +0 -2
  28. nwp500/models/mqtt_models.py +0 -2
  29. nwp500/models/schedule.py +0 -2
  30. nwp500/models/status.py +6 -4
  31. nwp500/models/tou.py +0 -2
  32. nwp500/mqtt/__init__.py +0 -2
  33. nwp500/mqtt/client.py +77 -46
  34. nwp500/mqtt/command_queue.py +47 -58
  35. nwp500/mqtt/connection.py +68 -73
  36. nwp500/mqtt/control.py +16 -6
  37. nwp500/mqtt/diagnostics.py +0 -2
  38. nwp500/mqtt/periodic.py +20 -9
  39. nwp500/mqtt/reconnection.py +109 -19
  40. nwp500/mqtt/state_tracker.py +4 -4
  41. nwp500/mqtt/subscriptions.py +63 -39
  42. nwp500/mqtt/utils.py +35 -4
  43. nwp500/mqtt_events.py +12 -14
  44. nwp500/openei.py +15 -3
  45. nwp500/reservations.py +0 -2
  46. nwp500/temperature.py +54 -247
  47. nwp500/topic_builder.py +0 -2
  48. nwp500/unit_system.py +24 -23
  49. nwp500/utils.py +0 -2
  50. {nwp500_python-8.1.2.dist-info → nwp500_python-9.0.0.dist-info}/METADATA +1 -3
  51. nwp500_python-9.0.0.dist-info/RECORD +58 -0
  52. {nwp500_python-8.1.2.dist-info → nwp500_python-9.0.0.dist-info}/WHEEL +1 -1
  53. nwp500_python-9.0.0.dist-info/scm_file_list.json +203 -0
  54. nwp500_python-9.0.0.dist-info/scm_version.json +8 -0
  55. nwp500/cli/commands.py +0 -91
  56. nwp500_python-8.1.2.dist-info/RECORD +0 -57
  57. {nwp500_python-8.1.2.dist-info → nwp500_python-9.0.0.dist-info}/entry_points.txt +0 -0
  58. {nwp500_python-8.1.2.dist-info → nwp500_python-9.0.0.dist-info}/licenses/LICENSE.txt +0 -0
  59. {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 & Caching
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, cast
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
- self._session = session or auth_client.session
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._session is None:
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 = cast(aiohttp.ClientSession, self._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: {str(e)}") from e
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
- from __future__ import annotations
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
- if self._owned_session:
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
- # Check if we have valid stored tokens
347
- if self._auth_response and self._auth_response.tokens:
348
- tokens = self._auth_response.tokens
349
- # If tokens are expired, refresh or re-authenticate
350
- if tokens.are_aws_credentials_expired:
351
- _logger.info(
352
- "Stored AWS credentials expired, re-authenticating..."
353
- )
354
- await self.sign_in(self._user_id, self._password)
355
- elif tokens.is_expired:
356
- _logger.info("Stored JWT token expired, refreshing...")
357
- await self.refresh_token(tokens.refresh_token)
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
- _logger.info("Using stored tokens, skipping authentication")
360
- else:
361
- # No stored tokens, perform full authentication
362
- await self.sign_in(self._user_id, self._password)
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: {str(e)}",
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 AWS credentials from old tokens if not in refresh
525
- # response
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: {str(e)}",
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: {str(e)}") from e
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
- _logger.info("AWS credentials expired, re-authenticating...")
625
- # Re-authenticate to get fresh AWS credentials
626
- await self.sign_in(self._user_id, self._password)
627
- return self._auth_response.tokens if self._auth_response else None
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(tokens.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
@@ -1,7 +1,5 @@
1
1
  """CLI package for nwp500-python."""
2
2
 
3
- from __future__ import annotations
4
-
5
3
  from .__main__ import run
6
4
  from .handlers import (
7
5
  handle_device_info_request,
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 asyncio.run(runner())
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
@@ -1,7 +1,5 @@
1
1
  """Monitoring and periodic status polling."""
2
2
 
3
- from __future__ import annotations
4
-
5
3
  import asyncio
6
4
  import logging
7
5
 
@@ -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
- status_dict["timestamp"] = datetime.now().isoformat()
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(file_path, "a", newline="") as csvfile:
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
- from __future__ import annotations
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 zip(items, items[1:], strict=False):
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
@@ -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
- with open(TOKEN_FILE, "w") as f:
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(TOKEN_FILE) as f:
48
+ with TOKEN_FILE.open() as f:
46
49
  data = json.load(f)
47
50
  email = data.get("email")
48
51
  if not email:
@@ -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__}: {str(e)}",
85
+ f"Cannot execute {func.__name__}: {e!s}",
88
86
  ) from e
89
87
 
90
88
  if cached_features is None: