pylxpweb 0.1.0__py3-none-any.whl → 0.5.2__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 (46) hide show
  1. pylxpweb/__init__.py +47 -2
  2. pylxpweb/api_namespace.py +241 -0
  3. pylxpweb/cli/__init__.py +3 -0
  4. pylxpweb/cli/collect_device_data.py +874 -0
  5. pylxpweb/client.py +387 -26
  6. pylxpweb/constants/__init__.py +481 -0
  7. pylxpweb/constants/api.py +48 -0
  8. pylxpweb/constants/devices.py +98 -0
  9. pylxpweb/constants/locations.py +227 -0
  10. pylxpweb/{constants.py → constants/registers.py} +72 -238
  11. pylxpweb/constants/scaling.py +479 -0
  12. pylxpweb/devices/__init__.py +32 -0
  13. pylxpweb/devices/_firmware_update_mixin.py +504 -0
  14. pylxpweb/devices/_mid_runtime_properties.py +1427 -0
  15. pylxpweb/devices/base.py +122 -0
  16. pylxpweb/devices/battery.py +589 -0
  17. pylxpweb/devices/battery_bank.py +331 -0
  18. pylxpweb/devices/inverters/__init__.py +32 -0
  19. pylxpweb/devices/inverters/_features.py +378 -0
  20. pylxpweb/devices/inverters/_runtime_properties.py +596 -0
  21. pylxpweb/devices/inverters/base.py +2124 -0
  22. pylxpweb/devices/inverters/generic.py +192 -0
  23. pylxpweb/devices/inverters/hybrid.py +274 -0
  24. pylxpweb/devices/mid_device.py +183 -0
  25. pylxpweb/devices/models.py +126 -0
  26. pylxpweb/devices/parallel_group.py +364 -0
  27. pylxpweb/devices/station.py +908 -0
  28. pylxpweb/endpoints/control.py +980 -2
  29. pylxpweb/endpoints/devices.py +249 -16
  30. pylxpweb/endpoints/firmware.py +43 -10
  31. pylxpweb/endpoints/plants.py +15 -19
  32. pylxpweb/exceptions.py +4 -0
  33. pylxpweb/models.py +708 -41
  34. pylxpweb/transports/__init__.py +78 -0
  35. pylxpweb/transports/capabilities.py +101 -0
  36. pylxpweb/transports/data.py +501 -0
  37. pylxpweb/transports/exceptions.py +59 -0
  38. pylxpweb/transports/factory.py +119 -0
  39. pylxpweb/transports/http.py +329 -0
  40. pylxpweb/transports/modbus.py +617 -0
  41. pylxpweb/transports/protocol.py +217 -0
  42. {pylxpweb-0.1.0.dist-info → pylxpweb-0.5.2.dist-info}/METADATA +130 -85
  43. pylxpweb-0.5.2.dist-info/RECORD +52 -0
  44. {pylxpweb-0.1.0.dist-info → pylxpweb-0.5.2.dist-info}/WHEEL +1 -1
  45. pylxpweb-0.5.2.dist-info/entry_points.txt +3 -0
  46. pylxpweb-0.1.0.dist-info/RECORD +0 -19
pylxpweb/client.py CHANGED
@@ -8,8 +8,15 @@ Key Features:
8
8
  - Session management with auto-reauthentication
9
9
  - Request caching with configurable TTL
10
10
  - Exponential backoff for rate limiting
11
+ - Automatic retry for transient errors (DATAFRAME_TIMEOUT, BUSY, etc.)
11
12
  - Support for injected aiohttp.ClientSession (Platinum tier requirement)
12
13
  - Comprehensive error handling
14
+
15
+ Retry Behavior:
16
+ - Transient errors (hardware communication timeouts) are automatically retried
17
+ up to MAX_TRANSIENT_ERROR_RETRIES times with exponential backoff
18
+ - Non-transient errors (permissions, invalid parameters) fail immediately
19
+ - Session expiration triggers automatic re-authentication and retry
13
20
  """
14
21
 
15
22
  from __future__ import annotations
@@ -24,6 +31,15 @@ from urllib.parse import urljoin
24
31
  import aiohttp
25
32
  from aiohttp import ClientTimeout
26
33
 
34
+ from .api_namespace import APINamespace
35
+ from .constants import (
36
+ BACKOFF_BASE_DELAY_SECONDS,
37
+ BACKOFF_MAX_DELAY_SECONDS,
38
+ HTTP_UNAUTHORIZED,
39
+ MAX_LOGIN_RETRIES,
40
+ MAX_TRANSIENT_ERROR_RETRIES,
41
+ TRANSIENT_ERROR_MESSAGES,
42
+ )
27
43
  from .endpoints import (
28
44
  AnalyticsEndpoints,
29
45
  ControlEndpoints,
@@ -69,6 +85,7 @@ class LuxpowerClient:
69
85
  verify_ssl: bool = True,
70
86
  timeout: int = 30,
71
87
  session: aiohttp.ClientSession | None = None,
88
+ iana_timezone: str | None = None,
72
89
  ) -> None:
73
90
  """Initialize the Luxpower API client.
74
91
 
@@ -77,16 +94,19 @@ class LuxpowerClient:
77
94
  password: API password for authentication
78
95
  base_url: Base URL for the API (default: EG4 Electronics endpoint)
79
96
  verify_ssl: Whether to verify SSL certificates
80
- timeout: Request timeout in seconds
81
- session: Optional aiohttp.ClientSession to use for requests.
82
- If not provided, a new session will be created.
83
- Platinum tier requirement: Support websession injection.
97
+ timeout: Request timeout in seconds (default: 30)
98
+ session: Optional aiohttp ClientSession for session injection
99
+ iana_timezone: Optional IANA timezone (e.g., "America/Los_Angeles")
100
+ for DST auto-detection. If not provided, DST auto-detection
101
+ will be disabled. This is required because the API doesn't
102
+ provide sufficient location data to reliably determine timezone.
84
103
  """
85
104
  self.username = username
86
105
  self.password = password
87
106
  self.base_url = base_url.rstrip("/")
88
107
  self.verify_ssl = verify_ssl
89
108
  self.timeout = ClientTimeout(total=timeout)
109
+ self.iana_timezone = iana_timezone
90
110
 
91
111
  # Session management
92
112
  self._session: aiohttp.ClientSession | None = session
@@ -94,6 +114,8 @@ class LuxpowerClient:
94
114
  self._session_id: str | None = None
95
115
  self._session_expires: datetime | None = None
96
116
  self._user_id: int | None = None
117
+ # Account level: "guest", "viewer", "operator", "owner", "installer"
118
+ self._account_level: str | None = None
97
119
 
98
120
  # Response cache with TTL configuration
99
121
  self._response_cache: dict[str, dict[str, Any]] = {}
@@ -109,15 +131,23 @@ class LuxpowerClient:
109
131
 
110
132
  # Backoff configuration
111
133
  self._backoff_config: dict[str, float] = {
112
- "base_delay": 1.0,
113
- "max_delay": 60.0,
134
+ "base_delay": BACKOFF_BASE_DELAY_SECONDS,
135
+ "max_delay": BACKOFF_MAX_DELAY_SECONDS,
114
136
  "exponential_factor": 2.0,
115
137
  "jitter": 0.1,
116
138
  }
117
139
  self._current_backoff_delay: float = 0.0
118
140
  self._consecutive_errors: int = 0
119
141
 
120
- # Endpoint modules (lazy-loaded)
142
+ # Hour tracking for automatic cache invalidation at boundaries
143
+ # Invalidates cache on first request after hour changes to ensure
144
+ # fresh data at date boundaries (especially midnight for daily energy values)
145
+ self._last_request_hour: int | None = None
146
+
147
+ # API namespace (new v0.2.0 interface)
148
+ self._api_namespace: APINamespace | None = None
149
+
150
+ # Endpoint modules (lazy-loaded) - kept for backward compatibility during transition
121
151
  self._plants_endpoints: PlantEndpoints | None = None
122
152
  self._devices_endpoints: DeviceEndpoints | None = None
123
153
  self._control_endpoints: ControlEndpoints | None = None
@@ -165,7 +195,38 @@ class LuxpowerClient:
165
195
  if self._session and not self._session.closed and self._owns_session:
166
196
  await self._session.close()
167
197
 
168
- # Endpoint Module Properties
198
+ # API Namespace (v0.2.0+)
199
+
200
+ @property
201
+ def api(self) -> APINamespace:
202
+ """Access all API endpoints through the api namespace.
203
+
204
+ This is the recommended way to access API endpoints in v0.2.0+.
205
+ It provides a clear separation between:
206
+ - Low-level API calls: `client.api.plants.get_plants()`
207
+ - High-level object interface: `client.get_station(plant_id)` (coming in Phase 1)
208
+
209
+ Returns:
210
+ APINamespace: The API namespace providing access to all endpoint groups.
211
+
212
+ Example:
213
+ ```python
214
+ async with LuxpowerClient(username, password) as client:
215
+ # Access plants endpoint
216
+ plants = await client.api.plants.get_plants()
217
+
218
+ # Access devices endpoint
219
+ runtime = await client.api.devices.get_inverter_runtime(serial)
220
+
221
+ # Access control endpoint
222
+ await client.api.control.start_quick_charge(serial)
223
+ ```
224
+ """
225
+ if self._api_namespace is None:
226
+ self._api_namespace = APINamespace(self)
227
+ return self._api_namespace
228
+
229
+ # Endpoint Module Properties (Deprecated - use client.api.* instead)
169
230
 
170
231
  @property
171
232
  def plants(self) -> PlantEndpoints:
@@ -216,6 +277,28 @@ class LuxpowerClient:
216
277
  self._firmware_endpoints = FirmwareEndpoints(self)
217
278
  return self._firmware_endpoints
218
279
 
280
+ @property
281
+ def account_level(self) -> str | None:
282
+ """Get detected account permission level.
283
+
284
+ Returns:
285
+ Account level: "guest", "viewer", "operator", "owner", "installer",
286
+ or None if not detected.
287
+
288
+ Note:
289
+ This is automatically detected after login by checking device endUser fields.
290
+ - "guest": Read-only access, parameter read/write blocked
291
+ - "viewer"/"operator": Limited access, control operations may be blocked
292
+ - "owner"/"installer": Full access to all operations
293
+
294
+ Example:
295
+ >>> async with LuxpowerClient(username, password) as client:
296
+ >>> print(f"Account level: {client.account_level}")
297
+ >>> if client.account_level in ("guest", "viewer", "operator"):
298
+ >>> print("Control operations may be restricted")
299
+ """
300
+ return self._account_level
301
+
219
302
  async def _apply_backoff(self) -> None:
220
303
  """Apply exponential backoff delay before API requests."""
221
304
  if self._current_backoff_delay > 0:
@@ -288,6 +371,83 @@ class LuxpowerClient:
288
371
  return self._response_cache[cache_key].get("response")
289
372
  return None
290
373
 
374
+ # ============================================================================
375
+ # Public Cache Management Methods
376
+ # ============================================================================
377
+
378
+ def clear_cache(self) -> None:
379
+ """Clear all cached API responses.
380
+
381
+ This forces fresh data retrieval on the next API calls.
382
+ Useful when you know data has changed and need immediate updates.
383
+
384
+ Example:
385
+ >>> client.clear_cache()
386
+ >>> # Next API calls will fetch fresh data
387
+ """
388
+ self._response_cache.clear()
389
+ _LOGGER.debug("Cache cleared (%d entries removed)", len(self._response_cache))
390
+
391
+ def invalidate_cache_for_device(self, serial_num: str) -> None:
392
+ """Invalidate all cached responses for a specific device.
393
+
394
+ Args:
395
+ serial_num: Device serial number (inverter, battery, or GridBOSS)
396
+
397
+ Example:
398
+ >>> # After changing device settings
399
+ >>> client.invalidate_cache_for_device("1234567890")
400
+ >>> # Next calls for this device will fetch fresh data
401
+ """
402
+ keys_to_remove = [key for key in self._response_cache if serial_num in key]
403
+
404
+ for key in keys_to_remove:
405
+ del self._response_cache[key]
406
+
407
+ _LOGGER.debug(
408
+ "Cache invalidated for device %s (%d entries removed)",
409
+ serial_num,
410
+ len(keys_to_remove),
411
+ )
412
+
413
+ @property
414
+ def cache_stats(self) -> dict[str, int | dict[str, int]]:
415
+ """Get cache statistics.
416
+
417
+ Returns:
418
+ dict with statistics:
419
+ - total_entries: Number of cached responses
420
+ - endpoints: Dict of endpoint types to entry counts
421
+
422
+ Example:
423
+ >>> stats = client.cache_stats
424
+ >>> print(f"Cache size: {stats['total_entries']}")
425
+ >>> for endpoint, count in stats['endpoints'].items():
426
+ >>> print(f" {endpoint}: {count} entries")
427
+ """
428
+ endpoints: dict[str, int] = {}
429
+
430
+ for key in self._response_cache:
431
+ # Extract endpoint type from cache key (format: "endpoint:params")
432
+ endpoint = key.split(":")[0] if ":" in key else key
433
+ endpoints[endpoint] = endpoints.get(endpoint, 0) + 1
434
+
435
+ return {
436
+ "total_entries": len(self._response_cache),
437
+ "endpoints": endpoints,
438
+ }
439
+
440
+ def _is_transient_error(self, error_msg: str) -> bool:
441
+ """Check if an error message indicates a transient error.
442
+
443
+ Args:
444
+ error_msg: The error message to check
445
+
446
+ Returns:
447
+ bool: True if the error is transient and should be retried
448
+ """
449
+ return any(transient in error_msg for transient in TRANSIENT_ERROR_MESSAGES)
450
+
291
451
  async def _request(
292
452
  self,
293
453
  method: str,
@@ -296,15 +456,24 @@ class LuxpowerClient:
296
456
  data: dict[str, Any] | None = None,
297
457
  cache_key: str | None = None,
298
458
  cache_endpoint: str | None = None,
459
+ _retry_count: int = 0,
299
460
  ) -> dict[str, Any]:
300
461
  """Make an HTTP request to the API.
301
462
 
463
+ Automatically invalidates cache on first request after hour boundary
464
+ to ensure fresh data at date rollovers (especially midnight for daily
465
+ energy values).
466
+
467
+ Automatically retries transient errors (e.g., DATAFRAME_TIMEOUT, BUSY)
468
+ with exponential backoff up to MAX_TRANSIENT_ERROR_RETRIES attempts.
469
+
302
470
  Args:
303
471
  method: HTTP method (GET, POST, etc.)
304
472
  endpoint: API endpoint (will be joined with base_url)
305
473
  data: Request data (will be form-encoded for POST)
306
474
  cache_key: Optional cache key for response caching
307
475
  cache_endpoint: Optional endpoint key for cache TTL lookup
476
+ _retry_count: Internal retry counter (do not set manually)
308
477
 
309
478
  Returns:
310
479
  dict: JSON response from the API
@@ -312,8 +481,21 @@ class LuxpowerClient:
312
481
  Raises:
313
482
  LuxpowerAuthError: If authentication fails
314
483
  LuxpowerConnectionError: If connection fails
315
- LuxpowerAPIError: If API returns an error
484
+ LuxpowerAPIError: If API returns an error (non-transient or max retries exceeded)
316
485
  """
486
+ # Auto-invalidate cache on first request after hour change
487
+ # This ensures fresh data after boundaries, especially midnight
488
+ current_hour = datetime.now().hour
489
+ if self._last_request_hour is not None and current_hour != self._last_request_hour:
490
+ _LOGGER.debug(
491
+ "Hour boundary crossed (hour %d → %d), invalidating all caches",
492
+ self._last_request_hour,
493
+ current_hour,
494
+ )
495
+ self.clear_cache()
496
+
497
+ self._last_request_hour = current_hour
498
+
317
499
  # Check cache if enabled
318
500
  if cache_key and cache_endpoint and self._is_cache_valid(cache_key, cache_endpoint):
319
501
  cached = self._get_cached_response(cache_key)
@@ -343,6 +525,29 @@ class LuxpowerClient:
343
525
  if not error_msg:
344
526
  # No standard error message, show entire response
345
527
  error_msg = f"No error message. Full response: {json_data}"
528
+
529
+ # Check if this is a transient error that should be retried
530
+ is_transient = self._is_transient_error(error_msg)
531
+ can_retry = _retry_count < MAX_TRANSIENT_ERROR_RETRIES
532
+ if is_transient and can_retry:
533
+ self._handle_request_error()
534
+ _LOGGER.warning(
535
+ "Transient API error '%s' (attempt %d/%d), retrying with backoff...",
536
+ error_msg,
537
+ _retry_count + 1,
538
+ MAX_TRANSIENT_ERROR_RETRIES,
539
+ )
540
+ # Retry with incremented counter
541
+ return await self._request(
542
+ method,
543
+ endpoint,
544
+ data=data,
545
+ cache_key=cache_key,
546
+ cache_endpoint=cache_endpoint,
547
+ _retry_count=_retry_count + 1,
548
+ )
549
+
550
+ # Non-transient error or max retries exceeded
346
551
  raise LuxpowerAPIError(f"API error (HTTP {response.status}): {error_msg}")
347
552
 
348
553
  # Cache successful response
@@ -352,14 +557,50 @@ class LuxpowerClient:
352
557
  self._handle_request_success()
353
558
  return json_data
354
559
 
560
+ except aiohttp.ContentTypeError as err:
561
+ # Session expired and API returned HTML login page instead of JSON
562
+ self._handle_request_error(err)
563
+ _LOGGER.warning(
564
+ "Got HTML response instead of JSON (session expired), attempting to re-authenticate"
565
+ )
566
+ try:
567
+ await self.login()
568
+ _LOGGER.debug("Re-authentication successful, retrying request")
569
+ # Retry the request with the new session
570
+ return await self._request(
571
+ method,
572
+ endpoint,
573
+ data=data,
574
+ cache_key=cache_key,
575
+ cache_endpoint=cache_endpoint,
576
+ _retry_count=_retry_count, # Preserve retry count
577
+ )
578
+ except LuxpowerAuthError:
579
+ # True authentication failure (wrong credentials, account locked)
580
+ _LOGGER.error("Re-authentication failed: invalid credentials")
581
+ raise
582
+ except LuxpowerConnectionError as login_err:
583
+ # Transient connection issue during re-auth - don't treat as auth failure
584
+ # This allows Home Assistant to retry automatically instead of requiring
585
+ # manual re-authentication (fixes issue #70)
586
+ _LOGGER.warning("Re-authentication failed due to connection issue: %s", login_err)
587
+ raise
588
+ except Exception as login_err:
589
+ # Other unexpected errors during re-auth - treat as connection issue
590
+ # to allow automatic retry rather than requiring manual intervention
591
+ _LOGGER.error("Re-authentication failed unexpectedly: %s", login_err)
592
+ raise LuxpowerConnectionError(
593
+ f"Re-authentication failed: {login_err}"
594
+ ) from login_err
595
+
355
596
  except aiohttp.ClientResponseError as err:
356
597
  self._handle_request_error(err)
357
- if err.status == 401:
598
+ if err.status == HTTP_UNAUTHORIZED:
358
599
  # Session expired - try to re-authenticate once
359
600
  _LOGGER.warning("Got 401 Unauthorized, attempting to re-authenticate")
360
601
  try:
361
602
  await self.login()
362
- _LOGGER.info("Re-authentication successful, retrying request")
603
+ _LOGGER.debug("Re-authentication successful, retrying request")
363
604
  # Retry the request with the new session
364
605
  return await self._request(
365
606
  method,
@@ -367,12 +608,33 @@ class LuxpowerClient:
367
608
  data=data,
368
609
  cache_key=cache_key,
369
610
  cache_endpoint=cache_endpoint,
611
+ _retry_count=_retry_count, # Preserve retry count
612
+ )
613
+ except LuxpowerAuthError:
614
+ # True authentication failure (wrong credentials, account locked)
615
+ _LOGGER.error("Re-authentication failed: invalid credentials")
616
+ raise
617
+ except LuxpowerConnectionError as login_err:
618
+ # Transient connection issue during re-auth - don't treat as auth failure
619
+ # This allows Home Assistant to retry automatically instead of requiring
620
+ # manual re-authentication (fixes issue #70)
621
+ _LOGGER.warning(
622
+ "Re-authentication failed due to connection issue: %s", login_err
370
623
  )
624
+ raise
371
625
  except Exception as login_err:
372
- _LOGGER.error("Re-authentication failed: %s", login_err)
373
- raise LuxpowerAuthError("Authentication failed") from err
626
+ # Other unexpected errors during re-auth - treat as connection issue
627
+ # to allow automatic retry rather than requiring manual intervention
628
+ _LOGGER.error("Re-authentication failed unexpectedly: %s", login_err)
629
+ raise LuxpowerConnectionError(
630
+ f"Re-authentication failed: {login_err}"
631
+ ) from login_err
374
632
  raise LuxpowerAPIError(f"HTTP {err.status}: {err.message}") from err
375
633
 
634
+ except LuxpowerAPIError:
635
+ # Re-raise our own exceptions (from transient error handling, etc)
636
+ raise
637
+
376
638
  except aiohttp.ClientError as err:
377
639
  self._handle_request_error(err)
378
640
  raise LuxpowerConnectionError(f"Connection error: {err}") from err
@@ -383,16 +645,30 @@ class LuxpowerClient:
383
645
 
384
646
  # Authentication
385
647
 
386
- async def login(self) -> LoginResponse:
648
+ async def login(self, _retry_count: int = 0) -> LoginResponse:
387
649
  """Authenticate with the API and establish a session.
388
650
 
651
+ This method includes automatic retry logic for transient failures
652
+ (network issues, temporary server errors) with exponential backoff.
653
+ This allows recovery from temporary issues without requiring manual
654
+ user intervention (fixes issue #70).
655
+
656
+ Args:
657
+ _retry_count: Internal retry counter (do not set manually)
658
+
389
659
  Returns:
390
660
  LoginResponse: Login response with user and plant information
391
661
 
392
662
  Raises:
393
- LuxpowerAuthError: If authentication fails
663
+ LuxpowerAuthError: If authentication fails due to invalid credentials
664
+ LuxpowerConnectionError: If connection fails after all retries
394
665
  """
395
- _LOGGER.info("Logging in as %s", self.username)
666
+ _LOGGER.debug(
667
+ "Logging in as %s (attempt %d/%d)",
668
+ self.username,
669
+ _retry_count + 1,
670
+ MAX_LOGIN_RETRIES,
671
+ )
396
672
 
397
673
  data = {
398
674
  "account": self.username,
@@ -400,18 +676,103 @@ class LuxpowerClient:
400
676
  "language": "ENGLISH",
401
677
  }
402
678
 
403
- response = await self._request("POST", "/WManage/api/login", data=data)
404
- login_data = LoginResponse.model_validate(response)
405
-
406
- # Store session info (session cookie is automatically handled by aiohttp)
407
- self._session_expires = datetime.now() + timedelta(hours=2)
408
- self._user_id = login_data.userId
409
- _LOGGER.info("Login successful, session expires at %s", self._session_expires)
410
-
411
- return login_data
679
+ try:
680
+ response = await self._request("POST", "/WManage/api/login", data=data)
681
+ login_data = LoginResponse.model_validate(response)
682
+
683
+ # Store session info (session cookie is automatically handled by aiohttp)
684
+ self._session_expires = datetime.now() + timedelta(hours=2)
685
+ self._user_id = login_data.userId
686
+ _LOGGER.debug("Login successful, session expires at %s", self._session_expires)
687
+
688
+ # Detect account level from endUser field
689
+ await self._detect_account_level()
690
+
691
+ return login_data
692
+
693
+ except LuxpowerAuthError:
694
+ # True authentication failure (wrong password, account locked)
695
+ # Don't retry - re-raise immediately
696
+ raise
697
+
698
+ except LuxpowerAPIError:
699
+ # API-level error (not transient) - don't retry
700
+ raise
701
+
702
+ except LuxpowerConnectionError as err:
703
+ # Transient connection issue - retry with backoff
704
+ if _retry_count < MAX_LOGIN_RETRIES - 1:
705
+ delay = self._backoff_config["base_delay"] * (2**_retry_count)
706
+ _LOGGER.warning(
707
+ "Login failed due to connection error (attempt %d/%d): %s. "
708
+ "Retrying in %.1f seconds...",
709
+ _retry_count + 1,
710
+ MAX_LOGIN_RETRIES,
711
+ err,
712
+ delay,
713
+ )
714
+ await asyncio.sleep(delay)
715
+ return await self.login(_retry_count=_retry_count + 1)
716
+ # Max retries exceeded
717
+ _LOGGER.error(
718
+ "Login failed after %d attempts due to connection errors: %s",
719
+ MAX_LOGIN_RETRIES,
720
+ err,
721
+ )
722
+ raise
412
723
 
413
724
  async def _ensure_authenticated(self) -> None:
414
725
  """Ensure we have a valid session, re-authenticating if needed."""
415
726
  if not self._session_expires or datetime.now() >= self._session_expires:
416
- _LOGGER.info("Session expired or missing, re-authenticating")
727
+ _LOGGER.debug("Session expired or missing, re-authenticating")
417
728
  await self.login()
729
+
730
+ async def _detect_account_level(self) -> None:
731
+ """Detect account permission level from device list endUser field.
732
+
733
+ This checks the endUser field from the first available device to determine
734
+ the account type. The detection is done once after login and cached.
735
+
736
+ Account levels:
737
+ - "guest": Read-only access, parameter read/write blocked
738
+ - "viewer" or username: Limited access, control operations may be blocked
739
+ - "installer": Full access to all operations
740
+
741
+ Note:
742
+ If endUser is a username (not "guest"), we classify it as "viewer/operator"
743
+ level since it's neither guest nor installer. This may indicate limited
744
+ control permissions.
745
+ """
746
+ if self._account_level is not None:
747
+ return # Already detected
748
+
749
+ try:
750
+ # Get plants to find a valid plant ID
751
+ plants_response = await self.api.plants.get_plants()
752
+ if not plants_response.rows:
753
+ _LOGGER.warning("No plants found, cannot detect account level")
754
+ return
755
+
756
+ # Get devices for first plant
757
+ devices_response = await self.api.devices.get_devices(plants_response.rows[0].plantId)
758
+ if not devices_response.rows:
759
+ _LOGGER.warning("No devices found, cannot detect account level")
760
+ return
761
+
762
+ # Check endUser field from first device
763
+ end_user = devices_response.rows[0].endUser
764
+ if end_user == "guest":
765
+ self._account_level = "guest"
766
+ elif end_user and ("installer" in end_user.lower()):
767
+ self._account_level = "installer"
768
+ elif end_user and end_user != "":
769
+ # Has endUser value but not guest or installer - likely viewer/operator
770
+ self._account_level = "viewer"
771
+ else:
772
+ # No endUser field or empty - assume owner (backward compatibility)
773
+ self._account_level = "owner"
774
+
775
+ _LOGGER.debug("Detected account level: %s (endUser=%s)", self._account_level, end_user)
776
+
777
+ except Exception as err:
778
+ _LOGGER.warning("Failed to detect account level: %s", err)