pypowerwall 0.18.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 (97) hide show
  1. pypowerwall/__init__.py +1282 -0
  2. pypowerwall/__main__.py +932 -0
  3. pypowerwall/api_lock.py +72 -0
  4. pypowerwall/cloud/__init__.py +0 -0
  5. pypowerwall/cloud/decorators.py +10 -0
  6. pypowerwall/cloud/exceptions.py +14 -0
  7. pypowerwall/cloud/mock_data.py +179 -0
  8. pypowerwall/cloud/pypowerwall_cloud.py +1303 -0
  9. pypowerwall/cloud/stubs.py +131 -0
  10. pypowerwall/cloud/teslapy/__init__.py +1282 -0
  11. pypowerwall/cloud/teslapy/endpoints.json +3121 -0
  12. pypowerwall/cloud/teslapy/option_codes.json +696 -0
  13. pypowerwall/exceptions.py +13 -0
  14. pypowerwall/fleetapi/__init__.py +0 -0
  15. pypowerwall/fleetapi/__main__.py +218 -0
  16. pypowerwall/fleetapi/decorators.py +10 -0
  17. pypowerwall/fleetapi/exceptions.py +14 -0
  18. pypowerwall/fleetapi/fleetapi.py +1101 -0
  19. pypowerwall/fleetapi/mock_data.py +179 -0
  20. pypowerwall/fleetapi/pypowerwall_fleetapi.py +815 -0
  21. pypowerwall/fleetapi/stubs.py +131 -0
  22. pypowerwall/helpers.py +64 -0
  23. pypowerwall/local/__init__.py +0 -0
  24. pypowerwall/local/exceptions.py +6 -0
  25. pypowerwall/local/pypowerwall_local.py +500 -0
  26. pypowerwall/local/tesla_pb2.py +222 -0
  27. pypowerwall/pypowerwall_base.py +95 -0
  28. pypowerwall/regex.py +22 -0
  29. pypowerwall/scan.py +309 -0
  30. pypowerwall/tedapi/__init__.py +2909 -0
  31. pypowerwall/tedapi/__main__.py +225 -0
  32. pypowerwall/tedapi/api_version.py +161 -0
  33. pypowerwall/tedapi/auth_mode.py +49 -0
  34. pypowerwall/tedapi/decorators.py +10 -0
  35. pypowerwall/tedapi/enums.py +66 -0
  36. pypowerwall/tedapi/exceptions.py +14 -0
  37. pypowerwall/tedapi/mock_data.py +179 -0
  38. pypowerwall/tedapi/protobuf/V2024_06/__init__.py +1 -0
  39. pypowerwall/tedapi/protobuf/V2024_06/tedapi_combined_pb2.py +123 -0
  40. pypowerwall/tedapi/protobuf/V2024_06/tedapi_pb2.py +64 -0
  41. pypowerwall/tedapi/protobuf/V2026_06/__init__.py +1 -0
  42. pypowerwall/tedapi/protobuf/V2026_06/tedapi_v2_common_pb2.py +36 -0
  43. pypowerwall/tedapi/protobuf/V2026_06/tedapi_v2_energy_device_pb2.py +385 -0
  44. pypowerwall/tedapi/protobuf/V2026_06/tedapi_v2_energy_registration_pb2.py +87 -0
  45. pypowerwall/tedapi/protobuf/V2026_06/tedapi_v2_google_rpc_pb2.py +39 -0
  46. pypowerwall/tedapi/protobuf/V2026_06/tedapi_v2_transport_pb2.py +39 -0
  47. pypowerwall/tedapi/protobuf/__init__.py +1 -0
  48. pypowerwall/tedapi/pypowerwall_tedapi.py +1029 -0
  49. pypowerwall/tedapi/queries/V2024_06.json +23 -0
  50. pypowerwall/tedapi/queries/V2026_06.json +72 -0
  51. pypowerwall/tedapi/queries/__init__.py +133 -0
  52. pypowerwall/tedapi/queries/base.py +31 -0
  53. pypowerwall/tedapi/stubs.py +131 -0
  54. pypowerwall/tedapi/system_info.py +158 -0
  55. pypowerwall/tedapi/tedapi_combined_pb2.py +7 -0
  56. pypowerwall/tedapi/tedapi_pb2.py +6 -0
  57. pypowerwall/tedapi/tedapi_v1r.py +606 -0
  58. pypowerwall/tesla_auth.py +1233 -0
  59. pypowerwall/tests/__init__.py +0 -0
  60. pypowerwall/tests/conftest.py +56 -0
  61. pypowerwall/tests/tedapi/__init__.py +0 -0
  62. pypowerwall/tests/tedapi/test_api_version.py +580 -0
  63. pypowerwall/tests/tedapi/test_init.py +369 -0
  64. pypowerwall/tests/tedapi/test_pw3_temperatures.py +314 -0
  65. pypowerwall/tests/tedapi/test_system_info.py +332 -0
  66. pypowerwall/tests/test_live_modes.py +152 -0
  67. pypowerwall/tests/test_mode_selection.py +286 -0
  68. pypowerwall/tests/test_powerwall.py +150 -0
  69. pypowerwall/tests/unit/__init__.py +0 -0
  70. pypowerwall/tests/unit/test_bearer_auth.py +790 -0
  71. pypowerwall/tests/unit/test_bearer_wiring.py +229 -0
  72. pypowerwall/tests/unit/test_cli_tedapi.py +147 -0
  73. pypowerwall/tests/unit/test_cloud_regressions.py +176 -0
  74. pypowerwall/tests/unit/test_credential_file_permissions.py +51 -0
  75. pypowerwall/tests/unit/test_facade_no_client.py +64 -0
  76. pypowerwall/tests/unit/test_fleetapi_operation_payload.py +53 -0
  77. pypowerwall/tests/unit/test_fleetapi_regressions.py +195 -0
  78. pypowerwall/tests/unit/test_fleetapi_token.py +86 -0
  79. pypowerwall/tests/unit/test_local_regressions.py +125 -0
  80. pypowerwall/tests/unit/test_native_energy_merge.py +295 -0
  81. pypowerwall/tests/unit/test_packaging.py +26 -0
  82. pypowerwall/tests/unit/test_parse_version.py +28 -0
  83. pypowerwall/tests/unit/test_powerwall_core.py +417 -0
  84. pypowerwall/tests/unit/test_tedapi_cached_fetch.py +742 -0
  85. pypowerwall/tests/unit/test_tedapi_regressions.py +369 -0
  86. pypowerwall/tests/unit/test_tedapi_remote_meter.py +646 -0
  87. pypowerwall/tests/unit/test_v1r_islanding.py +77 -0
  88. pypowerwall/tests/unit/test_v1r_register.py +133 -0
  89. pypowerwall/tests/unit/test_v1r_warnings.py +346 -0
  90. pypowerwall/tests/unit/test_version.py +48 -0
  91. pypowerwall/v1r_register.py +810 -0
  92. pypowerwall-0.18.0.dist-info/METADATA +1282 -0
  93. pypowerwall-0.18.0.dist-info/RECORD +97 -0
  94. pypowerwall-0.18.0.dist-info/WHEEL +5 -0
  95. pypowerwall-0.18.0.dist-info/entry_points.txt +2 -0
  96. pypowerwall-0.18.0.dist-info/licenses/LICENSE +21 -0
  97. pypowerwall-0.18.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1282 @@
1
+ # pyPowerWall Module
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Python module to interface with Tesla Solar Powerwall Gateway
5
+
6
+ Author: Jason A. Cox
7
+ For more information see https://github.com/jasonacox/pypowerwall
8
+
9
+ Features
10
+ * Works with Tesla Energy Gateways - Powerwall+
11
+ * Simple access through easy to use functions using customer credentials
12
+ * Will cache authentication to reduce load on Powerwall Gateway
13
+ * Will cache responses for 5s to limit number of calls to Powerwall Gateway
14
+ * Will re-use http connections to Powerwall Gateway for reduced load and faster response times
15
+ * Can use Tesla Cloud API instead of local Powerwall Gateway (if enabled)
16
+ * Uses Auth Cookie or Bearer Token for authorization (configurable)
17
+
18
+ Classes
19
+ Powerwall(host, password, email, timezone, pwcacheexpire, timeout, poolmaxsize,
20
+ cloudmode, siteid, authpath, authmode, cachefile, fleetapi, auto_select, retry_modes, gw_pwd)
21
+
22
+ Parameters
23
+ host # Hostname or IP of the Tesla gateway (optionally host:port for
24
+ non-standard HTTPS port, e.g. 10.0.1.99:8443; default port is 443)
25
+ password # Customer password for gateway
26
+ email # (required) Customer email for gateway / cloud
27
+ timezone # Desired timezone
28
+ pwcacheexpire = 5 # Set API cache timeout in seconds
29
+ timeout = 5 # Timeout for HTTPS calls in seconds
30
+ poolmaxsize = 10 # Pool max size for http connection re-use (persistent
31
+ connections disabled if zero)
32
+ cloudmode = False # If True, use Tesla cloud for data (default is False)
33
+ siteid = None # If cloudmode is True, use this siteid (default is None)
34
+ authpath = "" # Path to cloud auth and site files (default current directory)
35
+ authmode = "cookie" # "cookie" (default) or "token" - use cookie or bearer token for auth
36
+ cachefile = ".powerwall" # Path to cache file (default current directory)
37
+ fleetapi = False # If True, use Tesla FleetAPI for data (default is False)
38
+ auto_select = False # If True, select the best available mode to connect (default is False)
39
+ retry_modes = False # If True, retry connection to Powerwall - WARNING: blocks
40
+ indefinitely until a connection succeeds (daemon use)
41
+ gw_pwd = None # TEG Gateway password (used for local mode access to tedapi)
42
+ wifi_host = None # Optional WiFi TEDAPI host for v1r follower fallback
43
+
44
+ Functions
45
+ poll(api, jsonformat, raw, force) # Return data from Powerwall api (JSON string if jsonformat=True, bypass cache force=True)
46
+ post(api, payload, din, jsonformat) # Send payload to Powerwall api (JSON string if jsonformat=True)
47
+ level() # Return battery power level percentage
48
+ power() # Return power data returned as dictionary
49
+ site(verbose) # Return site sensor data (W or raw JSON if verbose=True)
50
+ solar(verbose): # Return solar sensor data (W or raw JSON if verbose=True)
51
+ battery(verbose): # Return battery sensor data (W or raw JSON if verbose=True)
52
+ load(verbose) # Return load sensor data (W or raw JSON if verbose=True)
53
+ grid() # Alias for site()
54
+ home() # Alias for load()
55
+ vitals(json) # Return Powerwall device vitals (dict or json if True)
56
+ strings(json, verbose) # Return solar panel string data
57
+ din() # Return DIN
58
+ uptime() # Return uptime - string hms format
59
+ version() # Return system version
60
+ status(param) # Return status (JSON) or individual param
61
+ site_name() # Return site name
62
+ temps() # Return Powerwall Temperatures
63
+ alerts() # Return array of Alerts from devices
64
+ system_status(json) # Returns the system status
65
+ battery_blocks(json) # Returns battery specific information merged from system_status() and vitals()
66
+ grid_status(type) # Return the power grid status, type ="string" (default), "json", or "numeric"
67
+ # - "string": "UP", "DOWN", "SYNCING"
68
+ # - "numeric": -1 (Syncing), 0 (DOWN), 1 (UP)
69
+ is_connected() # Returns True if able to connect and login to Powerwall
70
+ get_reserve(scale) # Get Battery Reserve Percentage
71
+ get_mode() # Get Current Battery Operation Mode
72
+ set_reserve(level) # Set Battery Reserve Percentage
73
+ set_mode(mode) # Set Current Battery Operation Mode
74
+ get_time_remaining() # Get the backup time remaining on the battery
75
+ set_operation(level, mode, json) # Set Battery Reserve Percentage and/or Operation Mode
76
+ set_grid_charging(mode) # Enable or disable grid charging (mode = True or False)
77
+ set_grid_export(mode) # Set grid export mode (mode = battery_ok, pv_only, never)
78
+ get_grid_charging() # Get the current grid charging mode
79
+ get_grid_export() # Get the current grid export mode
80
+ go_off_grid(confirm) # USE WITH EXTREME CARE: open the grid contactor (island the home) - requires confirm=True
81
+ reconnect_grid() # USE WITH EXTREME CARE: close the grid contactor (reconnect to grid)
82
+
83
+ Requirements
84
+ This module requires the following modules: requests, protobuf, teslapy
85
+ pip install requests protobuf teslapy
86
+ """
87
+ import json
88
+ import logging
89
+ import os.path
90
+ import re
91
+ import sys
92
+ import time
93
+ from typing import Optional, Union
94
+
95
+
96
+ def _release_tuple(version_string):
97
+ """Numeric release components of a version, e.g. "0.18.0rc1" -> (0, 18, 0).
98
+ Never raises, so a pre-release version string can't break import."""
99
+ return tuple(int(n) for n in re.findall(r"\d+", version_string.split("+")[0])[:3])
100
+
101
+
102
+ # Single source of truth for the package version — keep this a plain literal.
103
+ # pyproject.toml reads it at build time via [tool.setuptools.dynamic]
104
+ # (attr:), which parses this file without importing it.
105
+ __version__ = version = "0.18.0"
106
+ version_tuple = _release_tuple(__version__)
107
+ __author__ = 'jasonacox'
108
+
109
+ # noinspection PyPackageRequirements
110
+ import urllib3
111
+
112
+ from pypowerwall.cloud.pypowerwall_cloud import AUTHFILE, PyPowerwallCloud
113
+ from pypowerwall.exceptions import (InvalidBatteryReserveLevelException,
114
+ PyPowerwallInvalidConfigurationParameter)
115
+ from pypowerwall.fleetapi.fleetapi import CONFIGFILE
116
+ from pypowerwall.fleetapi.pypowerwall_fleetapi import PyPowerwallFleetAPI
117
+ from pypowerwall.local.pypowerwall_local import PyPowerwallLocal
118
+ from pypowerwall.pypowerwall_base import PyPowerwallBase, parse_version
119
+ from pypowerwall.regex import EMAIL_REGEX, HOST_REGEX, IPV4_6_REGEX
120
+ from pypowerwall.tedapi.api_version import TEDAPIApiVersion
121
+ from pypowerwall.tedapi.auth_mode import AuthMode
122
+ from pypowerwall.tedapi.pypowerwall_tedapi import PyPowerwallTEDAPI
123
+
124
+ urllib3.disable_warnings() # Disable SSL warnings
125
+
126
+
127
+ log = logging.getLogger(__name__)
128
+ log.debug('%s version %s', __name__, __version__)
129
+ log.debug('Python %s on %s', sys.version, sys.platform)
130
+
131
+ def set_debug(toggle=True, color=True):
132
+ """Enable verbose logging"""
133
+ if toggle:
134
+ if color:
135
+ logging.basicConfig(format='\x1b[31;1m%(levelname)s:%(message)s\x1b[0m', level=logging.DEBUG)
136
+ else:
137
+ logging.basicConfig(format='%(levelname)s:%(message)s', level=logging.DEBUG)
138
+ log.setLevel(logging.DEBUG)
139
+ log.debug("%s [%s]\n" % (__name__, __version__))
140
+ else:
141
+ log.setLevel(logging.NOTSET)
142
+
143
+
144
+ # pylint: disable=too-many-public-methods
145
+ class Powerwall(object):
146
+ def __init__(self, host="", password="", email="nobody@nowhere.com",
147
+ timezone="America/Los_Angeles", pwcacheexpire=5, timeout=5, poolmaxsize=10,
148
+ cloudmode=False, siteid=None, authpath="", authmode="cookie", cachefile=".powerwall",
149
+ fleetapi=False, auto_select=False, retry_modes=False, gw_pwd=None,
150
+ rsa_key_path=None, wifi_host=None, tedapi_api_version=TEDAPIApiVersion.V2024_06,
151
+ tedapi_auth_mode=AuthMode.BASIC):
152
+ """
153
+ Represents a Tesla Energy Gateway Powerwall device.
154
+
155
+ Args:
156
+ host = Hostname or IP address of Powerwall (e.g. 10.0.1.99); optionally
157
+ include a port for non-standard HTTPS access (e.g. 10.0.1.99:8443);
158
+ defaults to port 443 when no port is specified
159
+ password = Customer password set up on Powerwall gateway
160
+ email = Customer email
161
+ timezone = Timezone for location of Powerwall
162
+ (see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)
163
+ pwcacheexpire = Seconds to expire cached entries
164
+ timeout = Seconds for the timeout on http requests
165
+ poolmaxsize = Pool max size for http connection re-use (persistent connections disabled if zero)
166
+ cloudmode = If True, use Tesla cloud for data (default is False)
167
+ siteid = If cloudmode is True, use this siteid (default is None)
168
+ authpath = Path to cloud auth and site cache files (default current directory)
169
+ authmode = "cookie" (default) or "token" - use cookie or bearer token for authorization
170
+ cachefile = Path to cache file (default current directory)
171
+ fleetapi = If True, use Tesla FleetAPI for data (default is False)
172
+ auto_select = If True, select the best available mode to connect (default is False)
173
+ retry_modes = If True, retry connection to Powerwall - WARNING: blocks
174
+ indefinitely until a connection succeeds (daemon use)
175
+ gw_pwd = Full gateway password from QR sticker; used for TEDAPI (mode 4)
176
+ and auto-derived (last 5 chars) for v1r login (mode 5)
177
+ rsa_key_path = Path to RSA-4096 private key PEM for v1r LAN TEDapi access
178
+ tedapi_auth_mode = TEDAPI authentication mode: "basic" (default) HTTP
179
+ Basic Auth to 192.168.91.1, which is only reachable
180
+ over the Gateway's Wi-Fi; or "bearer" to log in via
181
+ /api/login/Basic and wrap queries in an AuthEnvelope,
182
+ which also works over the wired LAN IP. Bearer works
183
+ on solar-only gateways but NOT Powerwall 2 or
184
+ Powerwall 3 — for PW3 wired access use v1r mode
185
+ (rsa_key_path)
186
+ """
187
+
188
+ # Attributes
189
+ self.cachefile = cachefile # Stores auth session information
190
+ self.host = host
191
+ self.password = password
192
+ self.email = email
193
+ self.timezone = timezone
194
+ self.timeout = timeout # 5s timeout for http calls
195
+ self.poolmaxsize = poolmaxsize # pool max size for http connection re-use
196
+ self.auth = {} # caches auth cookies
197
+ self.token = None # caches bearer token
198
+ self.pwcacheexpire = pwcacheexpire # seconds to expire cache
199
+ self.cloudmode = cloudmode # cloud mode or local mode (default)
200
+ self.siteid = siteid # siteid for cloud mode
201
+ self.authpath = os.path.expanduser(authpath) # path to auth and site cache files
202
+ self.authmode = authmode # cookie or token
203
+ self.pwcooldown = 0 # rate limit cooldown time - pause api calls
204
+ self.vitals_api = True # vitals api is available for local mode
205
+ # Backend client - remains None if connect() fails for all modes so that
206
+ # method calls degrade to None instead of raising AttributeError
207
+ self.client: Optional[PyPowerwallBase] = None
208
+ self.fleetapi = fleetapi
209
+ self.retry_modes = retry_modes
210
+ self.mode = "unknown"
211
+ self.gw_pwd = gw_pwd # TEG Gateway password for TEDAPI mode
212
+ self.rsa_key_path = rsa_key_path # RSA key for v1r LAN TEDapi
213
+ self.wifi_host = wifi_host # WiFi TEDAPI host for v1r wifi fallback
214
+ # TEDAPIApiVersion.V2024_06 (default) or .V2026_06; coerce str inputs.
215
+ self.tedapi_api_version = TEDAPIApiVersion.coerce(tedapi_api_version)
216
+ # basic / bearer for TEDAPI; coerce str inputs (raises on bad).
217
+ self.tedapi_auth_mode = AuthMode.coerce(tedapi_auth_mode)
218
+ self.tedapi = False
219
+ self.tedapi_mode = "off" # off, full, hybrid
220
+
221
+ # Make certain assumptions here
222
+ if not self.host:
223
+ self.cloudmode = True
224
+ if self.cloudmode and not self.fleetapi:
225
+ self.mode = "cloud"
226
+ elif self.cloudmode and self.fleetapi:
227
+ self.mode = "fleetapi"
228
+ elif not self.cloudmode and not self.fleetapi:
229
+ self.mode = "local"
230
+ else:
231
+ self.mode = "unknown"
232
+
233
+ # Auto select mode if requested
234
+ if auto_select:
235
+ if self.host and not self.cloudmode and not self.fleetapi:
236
+ log.debug("Auto selecting local mode")
237
+ self.cloudmode = self.fleetapi = False
238
+ self.mode = "local"
239
+ elif os.path.exists(os.path.join(self.authpath, CONFIGFILE)):
240
+ log.debug("Auto selecting FleetAPI Mode")
241
+ self.cloudmode = self.fleetapi = True
242
+ self.mode = "fleetapi"
243
+ elif os.path.exists(os.path.join(self.authpath, AUTHFILE)):
244
+ if not self.email or self.email == "nobody@nowhere.com":
245
+ auth_file_path = os.path.join(self.authpath, AUTHFILE)
246
+ try:
247
+ with open(auth_file_path, 'r') as file:
248
+ auth = json.load(file)
249
+ self.email = list(auth.keys())[0]
250
+ except Exception as exc:
251
+ log.debug(f"Failed to load auth file '{auth_file_path}': {exc}")
252
+ self.cloudmode = True
253
+ self.fleetapi = False
254
+ self.mode = "cloud"
255
+ log.debug("Auto selecting Cloud Mode (email: %s)" % self.email)
256
+ else:
257
+ log.debug("Auto Select Failed: Unable to use local, cloud or fleetapi mode.")
258
+
259
+ # Validate provided parameters
260
+ self._validate_init_configuration()
261
+
262
+ # Connect to Powerwall
263
+ if not self.connect(self.retry_modes):
264
+ log.error("Unable to connect to Powerwall. Verify host, credentials, and network connectivity."
265
+ " Enable debug logging for detailed diagnostics.")
266
+
267
+ def connect(self, retry=False) -> bool:
268
+ """
269
+ Connect to Tesla Energy Gateway Powerwall
270
+
271
+ Tries the configured mode first and falls back to the other modes
272
+ (local -> fleetapi -> cloud -> local) on failure. If every mode fails,
273
+ the configured mode is restored so a later connect() retries in the
274
+ configured order.
275
+
276
+ Args:
277
+ retry = If True, keep cycling through the modes until one connects.
278
+ WARNING: this blocks the calling thread indefinitely (by
279
+ design for daemon use, e.g. the proxy) - it only returns
280
+ once a connection succeeds.
281
+ """
282
+ if self.mode == "unknown":
283
+ log.error("Unable to determine mode to connect.")
284
+ return False
285
+ # Snapshot the configured mode - fallback mutates self.mode/cloudmode/
286
+ # fleetapi/tedapi/tedapi_mode, and a total failure must not leave that
287
+ # mutation behind. tedapi/tedapi_mode matter as much as mode: the local-
288
+ # mode exception handler zeroes them, and a stale tedapi=False after a
289
+ # fully-failed connect(retry=False) wedged the proxy's TEDAPI recovery
290
+ # thread forever - its loop gate (`if not pw.tedapi: continue`) never
291
+ # reached the reconnect call again (#366).
292
+ configured_mode = (self.mode, self.cloudmode, self.fleetapi,
293
+ self.tedapi, self.tedapi_mode)
294
+ total_wait = 0
295
+ count = 0
296
+ while count < 3:
297
+ count += 1
298
+ if retry and count == 3:
299
+ log.warning("Failed to connect to Powerwall with all modes. Waiting 30s to retry "
300
+ "(retry enabled - blocking until a connection succeeds; "
301
+ "%ds cumulative wait so far)." % total_wait)
302
+ time.sleep(30)
303
+ total_wait += 30
304
+ count = 0
305
+ if self.mode == "local":
306
+ log.debug(f"password = {'[set]' if self.password else '[empty]'}, "
307
+ f"gw_pwd = {'[set]' if self.gw_pwd else '[empty]'}, "
308
+ f"rsa_key_path = {'[set]' if self.rsa_key_path else '[empty]'}")
309
+ try:
310
+ if self.rsa_key_path: # v1r LAN TEDapi mode (mode 5)
311
+ # Auto-derive customer password from gw_pwd if not explicitly set
312
+ pw = self.password
313
+ if not pw and self.gw_pwd:
314
+ pw = self.gw_pwd[-5:]
315
+ log.debug("Derived customer password from gw_pwd (last 5 characters)")
316
+ if not pw:
317
+ raise ValueError("v1r mode requires password or gw_pwd")
318
+ self.tedapi_mode = "v1r"
319
+ if self.gw_pwd and self.wifi_host:
320
+ log.debug("TEDAPI ** v1r (wifi fallback available) **")
321
+ else:
322
+ log.debug("TEDAPI ** v1r **")
323
+ self.client = PyPowerwallTEDAPI(
324
+ gw_pwd=self.gw_pwd or "",
325
+ pwcacheexpire=self.pwcacheexpire,
326
+ pwconfigexpire=self.pwcacheexpire,
327
+ timeout=self.timeout, host=self.host,
328
+ poolmaxsize=self.poolmaxsize,
329
+ v1r=True, password=pw,
330
+ rsa_key_path=self.rsa_key_path,
331
+ wifi_host=self.wifi_host,
332
+ tedapi_api_version=self.tedapi_api_version)
333
+ elif not self.password and self.gw_pwd: # Full TEDAPI WiFi (mode 4)
334
+ log.debug("TEDAPI ** full **")
335
+ self.tedapi_mode = "full"
336
+ self.client = PyPowerwallTEDAPI(self.gw_pwd, pwcacheexpire=self.pwcacheexpire,
337
+ pwconfigexpire=self.pwcacheexpire,
338
+ timeout=self.timeout, host=self.host,
339
+ poolmaxsize=self.poolmaxsize,
340
+ tedapi_api_version=self.tedapi_api_version,
341
+ auth_mode=self.tedapi_auth_mode,
342
+ timezone=self.timezone)
343
+ else: # Hybrid (password + gw_pwd) or local-only (password only)
344
+ self.tedapi_mode = "hybrid"
345
+ self.client = PyPowerwallLocal(self.host, self.password, self.email, self.timezone, self.timeout,
346
+ self.pwcacheexpire, self.poolmaxsize, self.authmode, self.cachefile,
347
+ self.gw_pwd)
348
+ self.client.authenticate()
349
+ self.cloudmode = self.fleetapi = False
350
+ self.tedapi = self.client.tedapi
351
+ if not self.tedapi:
352
+ self.tedapi_mode = "off"
353
+ return True
354
+ except Exception as exc:
355
+ log.warning(f"Failed to connect using Local mode: {exc} - trying fleetapi mode.")
356
+ self.tedapi = False
357
+ self.tedapi_mode = "off"
358
+ self.mode = "fleetapi"
359
+ continue
360
+ if self.mode == "fleetapi":
361
+ try:
362
+ self.client = PyPowerwallFleetAPI(self.email, self.pwcacheexpire, self.timeout, self.siteid,
363
+ self.authpath)
364
+ self.client.authenticate()
365
+ self.cloudmode = self.fleetapi = True
366
+ self.siteid = self.client.siteid
367
+ return True
368
+ except Exception as exc:
369
+ log.warning(f"Failed to connect using FleetAPI mode: {exc} - trying cloud mode.")
370
+ self.mode = "cloud"
371
+ continue
372
+ if self.mode == "cloud":
373
+ try:
374
+ self.client = PyPowerwallCloud(self.email, self.pwcacheexpire, self.timeout, self.siteid, self.authpath)
375
+ self.client.authenticate()
376
+ self.cloudmode = True
377
+ self.fleetapi = False
378
+ self.siteid = self.client.siteid
379
+ return True
380
+ except Exception as exc:
381
+ log.warning(f"Failed to connect using Cloud mode: {exc} - trying local mode.")
382
+ self.mode = "local"
383
+ continue
384
+ # Total failure - restore the configured mode so a subsequent
385
+ # connect() retries in the configured order, not where fallback left off
386
+ (self.mode, self.cloudmode, self.fleetapi,
387
+ self.tedapi, self.tedapi_mode) = configured_mode
388
+ return False
389
+
390
+ def _no_client(self) -> bool:
391
+ """Return True (and log an error) if no backend client is connected."""
392
+ if self.client is None:
393
+ log.error("Not connected to Powerwall - no backend client available. "
394
+ "Check connection settings and call connect().")
395
+ return True
396
+ return False
397
+
398
+ def is_connected(self):
399
+ """
400
+ Attempt connection with Tesla Energy Gateway
401
+
402
+ Return True if able to successfully connect and login to Powerwall
403
+ """
404
+ # noinspection PyBroadException
405
+ try:
406
+ if self.status() is None:
407
+ return False
408
+ return True
409
+ except Exception:
410
+ return False
411
+
412
+ # pylint: disable=inconsistent-return-statements
413
+ def poll(self, api='/api/site_info/site_name', jsonformat=False, raw=False, recursive=False,
414
+ force=False) -> Optional[Union[dict, list, str, bytes]]:
415
+ """
416
+ Query Tesla Energy Gateway Powerwall for API Response
417
+
418
+ Args:
419
+ api = URI
420
+ jsonformat = If True, return JSON format otherwise return Python Dictionary
421
+ raw = If True, send raw data back (useful for binary responses, has no meaning in Cloud mode)
422
+ recursive = If True, this is a recursive call and do not allow additional recursive calls
423
+ force = If True, bypass the cache and make the API call to the gateway, has no meaning in Cloud mode
424
+ """
425
+ if self._no_client():
426
+ return None
427
+ payload = self.client.poll(api, force, recursive, raw)
428
+ if jsonformat:
429
+ try:
430
+ return json.dumps(payload)
431
+ except (TypeError, ValueError):
432
+ log.error(f"Unable to dump response '{payload}' as JSON. I know you asked for it, sorry.")
433
+ return None
434
+ else:
435
+ return payload
436
+
437
+ def post(self, api: str, payload: Optional[dict], din: Optional[str] = None, jsonformat=False, raw=False,
438
+ recursive=False) -> Optional[Union[dict, list, str, bytes]]:
439
+ """
440
+ Send a command to Tesla Energy Gateway
441
+
442
+ Args:
443
+ api = URI
444
+ payload = Arbitrary payload to send
445
+ din = System DIN (ignored in Cloud mode)
446
+ raw = If True, send raw data back (useful for binary responses, has no meaning in Cloud mode)
447
+ recursive = If True, this is a recursive call and do not allow additional recursive calls
448
+ """
449
+ if self._no_client():
450
+ return None
451
+ response = self.client.post(api, payload, din, recursive, raw)
452
+ if jsonformat:
453
+ try:
454
+ return json.dumps(response)
455
+ except (TypeError, ValueError):
456
+ log.error(f"Unable to dump response '{response}' as JSON. I know you asked for it, sorry.")
457
+ return None
458
+ else:
459
+ return response
460
+
461
+ def level(self, scale=False):
462
+ """
463
+ Battery Level Percentage
464
+ Note: Tesla App reserves 5% of battery => ( (batterylevel / 0.95) - (5 / 0.95) )
465
+ Args:
466
+ scale = If True, convert battery level to app scale value
467
+ """
468
+ # Return power level percentage for battery
469
+ try:
470
+ payload = self.poll('/api/system_status/soe')
471
+ if payload is not None and 'percentage' in payload:
472
+ level = payload['percentage']
473
+ if scale:
474
+ level = (level / 0.95) - (5 / 0.95)
475
+ return level
476
+ return None
477
+ except Exception as e:
478
+ log.error(f"level(): Exception {type(e).__name__}: {e}")
479
+ raise
480
+
481
+ def power(self) -> dict:
482
+ """
483
+ Power Usage for Site, Solar, Battery and Load
484
+ """
485
+ # Return power for (site, solar, battery, load) as dictionary
486
+ if self._no_client():
487
+ return None
488
+ return self.client.power()
489
+
490
+ def vitals(self, jsonformat=False):
491
+ """
492
+ Device Vitals Data
493
+
494
+ Args:
495
+ jsonformat = If True, return JSON format otherwise return Python Dictionary
496
+ """
497
+ if self._no_client():
498
+ return None
499
+ output = self.client.vitals()
500
+
501
+ # Return result
502
+ if jsonformat:
503
+ json_out = json.dumps(output, indent=4, sort_keys=True)
504
+ return json_out
505
+ else:
506
+ return output
507
+
508
+ def strings(self, jsonformat=False, verbose=False):
509
+ """
510
+ Solar Strings Data (current, voltage, power, state, connected)
511
+
512
+ Args:
513
+ jsonformat = If True, return JSON format otherwise return Python Dictionary
514
+ verbose = If True, return all String data details otherwise basics
515
+ """
516
+ result = {}
517
+ devicemap = ['', '1', '2', '3', '4', '5', '6', '7', '8']
518
+ deviceidx = 0
519
+ v: dict = self.vitals() or {}
520
+ for device in v:
521
+ if device.split('--')[0] == 'PVAC':
522
+ # Check for PVS data
523
+ look = "PVS" + str(device)[4:]
524
+ if look in v:
525
+ # Inject the PVS string data into the dictionary
526
+ for ee in v[look]:
527
+ if 'String' in ee:
528
+ v[device][ee] = v[look][ee]
529
+ if verbose:
530
+ result[device] = {}
531
+ # PVAC_Pout may be missing depending on firmware - default to None
532
+ result[device]['PVAC_Pout'] = v[device].get('PVAC_Pout')
533
+ for e in v[device]:
534
+ if 'PVAC_PVCurrent' in e or 'PVAC_PVMeasuredPower' in e or \
535
+ 'PVAC_PVMeasuredVoltage' in e or 'PVAC_PvState' in e or \
536
+ 'PVS_String' in e:
537
+ result[device][e] = v[device][e]
538
+ else: # simplified results
539
+ for e in v[device]:
540
+ if 'PVAC_PVCurrent' in e or 'PVAC_PVMeasuredPower' in e or \
541
+ 'PVAC_PVMeasuredVoltage' in e or 'PVAC_PvState' in e or \
542
+ 'PVS_String' in e:
543
+ # Guard against more PVAC devices than devicemap entries
544
+ name = e[-1] + devicemap[deviceidx % len(devicemap)]
545
+ if 'Current' in e:
546
+ idxname = 'Current'
547
+ elif 'Power' in e:
548
+ idxname = 'Power'
549
+ elif 'Voltage' in e:
550
+ idxname = 'Voltage'
551
+ elif 'State' in e:
552
+ idxname = 'State'
553
+ elif 'Connected' in e:
554
+ idxname = 'Connected'
555
+ name = e[10] + devicemap[deviceidx % len(devicemap)]
556
+ else:
557
+ idxname = 'Unknown'
558
+ if name not in result:
559
+ result[name] = {}
560
+ result[name][idxname] = v[device][e]
561
+ # if
562
+ # for
563
+ deviceidx += 1
564
+ # else
565
+ # If no devices found pull from /api/solar_powerwall
566
+ if not v:
567
+ # Build a string map: A, B, C, D, A1, B2, etc.
568
+ string_map = []
569
+ for number in ['', '1', '2', '3', '4', '5', '6', '7', '8']:
570
+ for letter in ['A', 'B', 'C', 'D']:
571
+ string_map.append(letter + number)
572
+ payload: dict = self.poll('/api/solar_powerwall') or {}
573
+ if payload and 'pvac_status' in payload:
574
+ # Strings are in PVAC status section
575
+ pvac = payload['pvac_status']
576
+ if 'string_vitals' in pvac:
577
+ i = 0
578
+ for string in pvac['string_vitals']:
579
+ if i >= len(string_map):
580
+ # More strings than the map can name - ignore the extras
581
+ log.debug("strings(): more than %d string_vitals entries - ignoring extras"
582
+ % len(string_map))
583
+ break
584
+ name = string_map[i]
585
+ result[name] = {}
586
+ result[name]['Connected'] = string['connected']
587
+ result[name]['Voltage'] = string['measured_voltage']
588
+ result[name]['Current'] = string['current']
589
+ result[name]['Power'] = string['measured_power']
590
+ i += 1
591
+ # Return result
592
+ if jsonformat:
593
+ return json.dumps(result, indent=4, sort_keys=True)
594
+ else:
595
+ return result
596
+
597
+ # Pull Power Data
598
+ def site(self, verbose=False):
599
+ """ Grid Usage """
600
+ if self._no_client():
601
+ return None
602
+ return self.client.fetchpower('site', verbose)
603
+
604
+ def solar(self, verbose=False):
605
+ """" Solar Power Generation """
606
+ if self._no_client():
607
+ return None
608
+ return self.client.fetchpower('solar', verbose)
609
+
610
+ def battery(self, verbose=False):
611
+ """ Battery Power Flow """
612
+ if self._no_client():
613
+ return None
614
+ return self.client.fetchpower('battery', verbose)
615
+
616
+ def load(self, verbose=False):
617
+ """ Home Power Usage """
618
+ if self._no_client():
619
+ return None
620
+ return self.client.fetchpower('load', verbose)
621
+
622
+ # Helpful Power Aliases
623
+ def grid(self, verbose=False):
624
+ """ Grid Power Usage """
625
+ return self.site(verbose)
626
+
627
+ def home(self, verbose=False):
628
+ """ Home Power Usage """
629
+ return self.load(verbose)
630
+
631
+ # Shortcut Functions
632
+ def site_name(self) -> Optional[str]:
633
+ """ System Site Name """
634
+ payload = self.poll('/api/site_info/site_name')
635
+ try:
636
+ site_name = payload['site_name']
637
+ except Exception as exc:
638
+ log.debug(f"ERROR unable to parse payload '{payload}' for site_name: {exc}")
639
+ site_name = None
640
+ return site_name
641
+
642
+ def status(self, param=None, jsonformat=False) -> Union[dict, str, None]:
643
+ """
644
+ Return Systems Status
645
+
646
+ Args:
647
+ param = only respond with this param data
648
+ jsonformat = If True, return JSON format otherwise return Python Dictionary
649
+
650
+ Available param:
651
+ din = payload['din']
652
+ start_time = payload['start_time-time']
653
+ up_time_seconds = payload['up_time_seconds']
654
+ is_new = payload['is_new']
655
+ version = payload['version']
656
+ githash = payload['git_hash']
657
+ commission_count = payload['commission_count']
658
+ device_type = payload['device_type']
659
+ sync_type = payload['sync_type']
660
+ leader = payload['leader']
661
+ followers = payload['followers']
662
+ cellular_disabled = payload['cellular_disabled']
663
+ """
664
+ if self._no_client():
665
+ return None
666
+ payload = self.client.poll('/api/status')
667
+ if payload is None:
668
+ return None
669
+ if param is None:
670
+ if jsonformat:
671
+ return json.dumps(payload, indent=4, sort_keys=True)
672
+ else:
673
+ return payload
674
+ else:
675
+ if param in payload:
676
+ return payload[param]
677
+ else:
678
+ log.debug('ERROR unable to find %s in payload: %r' % (param, payload))
679
+ return None
680
+
681
+ def version(self, int_value=False) -> Union[int, str, None]:
682
+ """ Firmware Version """
683
+ if not int_value:
684
+ return self.status('version')
685
+ # Convert version to integer
686
+ return parse_version(self.status('version'))
687
+
688
+ def uptime(self) -> Union[str, None]:
689
+ """ System Uptime """
690
+ return self.status('up_time_seconds')
691
+
692
+ def din(self) -> Optional[str]:
693
+ """ System DIN """
694
+ return self.status('din')
695
+
696
+ def temps(self, jsonformat=False) -> Optional[Union[dict, str]]:
697
+ """
698
+ Temperatures of Powerwalls
699
+
700
+ Powerwall 2 reports the thermal controller ambient (TETHC blocks,
701
+ THC_AmbientTemp). Powerwall 3 has no thermal controller; it reports the
702
+ hottest battery-pack reading (TEPOD blocks, HVP_PackTempMax) for each
703
+ Powerwall 3 and expansion pack - None for a battery without a reading
704
+ while others report one, so positions match the /pod numbering. All
705
+ values are degrees C. The full PW3 breakdown (pack min, shunt, inverter
706
+ ambient) is in vitals().
707
+
708
+ Args:
709
+ jsonformat = If True, return JSON format otherwise return Python Dictionary
710
+ """
711
+ temps = {}
712
+ devices: dict = self.vitals() or {}
713
+ # PW3 battery blocks always carry HVP_PackTempMax (None when unavailable).
714
+ # Once any reports a reading, list every PW3 block - None included - so the
715
+ # proxy's /temps/pw PWn numbering stays aligned with /pod; with no readings
716
+ # at all, list none (unchanged {} on firmware without the signal).
717
+ pw3_blocks = [d for d in devices if d.startswith('TEPOD')
718
+ and isinstance(devices[d], dict) and 'HVP_PackTempMax' in devices[d]]
719
+ pw3_has_reading = any(devices[d]['HVP_PackTempMax'] is not None for d in pw3_blocks)
720
+ for device in devices:
721
+ if device.startswith('TETHC'):
722
+ temps[device] = devices[device].get('THC_AmbientTemp')
723
+ elif pw3_has_reading and device in pw3_blocks:
724
+ temps[device] = devices[device]['HVP_PackTempMax']
725
+ if jsonformat:
726
+ return json.dumps(temps, indent=4, sort_keys=True)
727
+ else:
728
+ return temps
729
+
730
+ def alerts(self, jsonformat=False, alertsonly=True) -> Union[list, str]:
731
+ """
732
+ Return Array of Alerts from all Devices
733
+
734
+ Args:
735
+ jsonformat = If True, return JSON format otherwise return Python Dictionary
736
+ alertonly = If True, return only alerts without device name
737
+ """
738
+ alerts = set()
739
+ device_alerts = set() # (device, alert) tuples - dicts are unhashable, rebuilt at return
740
+ devices: dict = self.vitals() or {}
741
+ if devices:
742
+ for device in devices:
743
+ if not 'alerts' in devices[device]:
744
+ continue
745
+ for i in devices[device]['alerts']:
746
+ if alertsonly:
747
+ alerts.add(i)
748
+ continue
749
+ device_alerts.add((device, i))
750
+ elif not devices and alertsonly is True:
751
+ # Vitals API is not present in firmware versions > 23.44 for local mode.
752
+ # This is a workaround to get alerts from the /api/solar_powerwall endpoint
753
+ data: dict = self.poll('/api/solar_powerwall') or {}
754
+ pvac_alerts = data.get('pvac_alerts') or {}
755
+ for alert, value in pvac_alerts.items():
756
+ if value is True:
757
+ alerts.add(alert)
758
+ pvs_alerts = data.get('pvs_alerts') or {}
759
+ for alert, value in pvs_alerts.items():
760
+ if value is True:
761
+ alerts.add(alert)
762
+
763
+ # Augment with inferred alerts from the grid_status
764
+ grid_status = self.poll('/api/system_status/grid_status')
765
+ if grid_status:
766
+ alert = grid_status.get('grid_status')
767
+ if grid_status.get('grid_services_active'):
768
+ alerts.add('GridServicesActive')
769
+ elif alert:
770
+ alerts.add(alert)
771
+
772
+ # In the future, if more replacements are needed, create a utility function here.
773
+ normalized_alerts = list({s.replace("SystemGridConnected", "SystemConnectedToGrid") for s in alerts})
774
+ if not alertsonly:
775
+ # Append per-device alerts as {device: alert} entries
776
+ normalized_alerts += [{device: alert} for device, alert in sorted(device_alerts)]
777
+
778
+ if jsonformat:
779
+ return json.dumps(normalized_alerts, indent=4, sort_keys=True)
780
+ return normalized_alerts
781
+
782
+ def get_reserve(self, scale=True, force=False) -> Optional[float]:
783
+ """
784
+ Get Battery Reserve Percentage
785
+ Tesla App reserves 5% of battery => ( (batterylevel / 0.95) - (5 / 0.95) )
786
+
787
+ Args:
788
+ scale = If True (default) use Tesla's 5% reserve calculation
789
+ """
790
+ data = self.poll('/api/operation', force=force)
791
+ if data is not None and 'backup_reserve_percent' in data:
792
+ percent = float(data['backup_reserve_percent'])
793
+ if scale:
794
+ # Get percentage based on Tesla App scale
795
+ percent = max(0, float((percent / 0.95) - (5 / 0.95)))
796
+ return percent
797
+ return None
798
+
799
+ def get_mode(self, force=False) -> Optional[float]:
800
+ """
801
+ Get Battery Operation Mode
802
+ """
803
+ data = self.poll('/api/operation', force=force)
804
+ if data and data.get('real_mode'):
805
+ return data['real_mode']
806
+ return None
807
+
808
+ def set_reserve(self, level: float) -> Optional[dict]:
809
+ """
810
+ Set battery reserve level.
811
+
812
+ Args:
813
+ level: Set battery reserve level in percents (range of 0-100 is accepted)
814
+
815
+ Returns:
816
+ Dictionary with operation results.
817
+ """
818
+ return self.set_operation(level=level)
819
+
820
+ def set_mode(self, mode: str) -> Optional[dict]:
821
+ """
822
+ Set battery operation mode.
823
+
824
+ Args:
825
+ mode: Set battery operation mode (self_consumption, backup, autonomous, etc.)
826
+
827
+ Returns:
828
+ Dictionary with operation results.
829
+ """
830
+ return self.set_operation(mode=mode)
831
+
832
+ def set_operation(self, level: Optional[float] = None, mode: Optional[str] = None,
833
+ jsonformat: bool = False) -> Optional[Union[dict, str]]:
834
+ """
835
+ Set battery operation mode and reserve level.
836
+
837
+ Args:
838
+ level: Set battery reserve level in percents (range of 0-100 is accepted)
839
+ mode: Set battery operation mode (self_consumption, backup, autonomous, etc.)
840
+ jsonformat: Set to True to receive json formatted string
841
+
842
+ Returns:
843
+ Dictionary with operation results, if jsonformat is False, else a JSON string
844
+ """
845
+ if level is not None and (level < 0 or level > 100):
846
+ log.error("Level can be in range of 0 to 100 only.")
847
+ return None
848
+
849
+ # The local gateway /api/operation is a full overwrite: an omitted field
850
+ # would clobber the current setting, so back-fill from the live values.
851
+ # Cloud/fleetapi/tedapi accept partial payloads, and back-filling there is
852
+ # actively harmful: Tesla applies BACKUP_RESERVE and OPERATION_MODE as two
853
+ # asynchronous commands, so a back-filled reserve write races the mode
854
+ # change and the mode command can be silently dropped (observed on PW3
855
+ # whenever the current reserve is 0). Partial-payload backends therefore
856
+ # write only the fields the caller actually set.
857
+ full_overwrite = isinstance(self.client, PyPowerwallLocal)
858
+
859
+ payload = {}
860
+ if level is not None:
861
+ # Explicit reserve: keep the numeric value. In particular 0 must stay
862
+ # 0 - the legacy coercion of 0 to boolean False (a v1-local-API-era
863
+ # "leave unchanged" marker) made the cloud path treat every
864
+ # reserve-0 write as a real BACKUP_RESERVE=0 command racing any mode
865
+ # change in the same call.
866
+ payload['backup_reserve_percent'] = level
867
+ elif full_overwrite:
868
+ # Back-fill the current reserve so the write does not change it. The
869
+ # local backend passes the payload verbatim to the gateway's raw-scale
870
+ # /api/operation, so read it back raw.
871
+ level = self.get_reserve(scale=False)
872
+ if level is None:
873
+ log.error("Unable to determine current reserve level - unable to set operation.")
874
+ return None
875
+ payload['backup_reserve_percent'] = level
876
+
877
+ if mode:
878
+ payload['real_mode'] = mode
879
+ elif full_overwrite:
880
+ mode = self.get_mode()
881
+ if mode:
882
+ payload['real_mode'] = mode
883
+
884
+ if not payload and not full_overwrite:
885
+ # Partial-payload backends raise on an empty body; without a
886
+ # caller-set field there is nothing to write, so fail soft like
887
+ # the other invalid-input paths instead of raising.
888
+ log.error("No operation to set - pass level and/or mode.")
889
+ return None
890
+
891
+ log.debug(f"Setting operation: {payload}")
892
+
893
+ result = self.post(api='/api/operation', payload=payload, din=self.din(), jsonformat=jsonformat)
894
+ return result
895
+
896
+ def schedule_max_backup(self, duration_seconds=7200):
897
+ """Schedule manual backup event (max backup / storm watch mode) via v1r TEGMessages."""
898
+ if self.tedapi_mode != "v1r":
899
+ log.error("schedule_max_backup requires v1r LAN mode (tedapi_mode=%s)", self.tedapi_mode)
900
+ return None
901
+ return self.client.schedule_max_backup(duration_seconds=duration_seconds)
902
+
903
+ def cancel_max_backup(self):
904
+ """Cancel the current manual backup event via v1r TEGMessages."""
905
+ if self.tedapi_mode != "v1r":
906
+ log.error("cancel_max_backup requires v1r LAN mode (tedapi_mode=%s)", self.tedapi_mode)
907
+ return None
908
+ return self.client.cancel_max_backup()
909
+
910
+ def get_backup_events(self):
911
+ """Get current backup events via v1r TEGMessages."""
912
+ if self.tedapi_mode != "v1r":
913
+ log.error("get_backup_events requires v1r LAN mode (tedapi_mode=%s)", self.tedapi_mode)
914
+ return None
915
+ return self.client.get_backup_events()
916
+
917
+ # noinspection PyShadowingBuiltins
918
+ def grid_status(self, output_type="string", type=None) -> Optional[Union[str, int]]:
919
+ """
920
+ Get the status of the grid
921
+
922
+ Args:
923
+ output_type == "string" (default) returns: "UP", "DOWN", "SYNCING"
924
+ output_type == "json" return raw JSON
925
+ output_type == "numeric" return -1 (Syncing), 0 (DOWN), 1 (UP)
926
+
927
+ If type (deprecated) is set, it will override output_type
928
+ """
929
+ if type:
930
+ output_type = type # Backward compatibility
931
+ if output_type not in ['json', 'string', 'numeric']:
932
+ log.error(f"Invalid value for parameter 'output_type': {output_type}")
933
+ raise ValueError("Invalid value for parameter 'output_type': " + str(output_type))
934
+
935
+ try:
936
+ payload: dict = self.poll('/api/system_status/grid_status')
937
+
938
+ if payload is None:
939
+ log.error("Failed to get /api/system_status/grid_status")
940
+ return None
941
+
942
+ if output_type == "json":
943
+ return json.dumps(payload, indent=4, sort_keys=True)
944
+
945
+ gridmap = {'SystemGridConnected': {'string': 'UP', 'numeric': 1},
946
+ 'SystemIslandedActive': {'string': 'DOWN', 'numeric': 0},
947
+ 'SystemTransitionToGrid': {'string': 'SYNCING', 'numeric': -1},
948
+ 'SystemTransitionToIsland': {'string': 'SYNCING', 'numeric': -1},
949
+ 'SystemIslandedReady': {'string': 'SYNCING', 'numeric': -1},
950
+ 'SystemMicroGridFaulted': {'string': 'DOWN', 'numeric': 0},
951
+ 'SystemWaitForUser': {'string': 'DOWN', 'numeric': 0}}
952
+
953
+ grid_status = payload.get('grid_status')
954
+ status = gridmap.get(grid_status, {}).get(output_type)
955
+ if status is None:
956
+ log.warning(f"Unable to parse payload '{payload}' for grid_status of output_type: {output_type}")
957
+ return status
958
+ except Exception as e:
959
+ log.error(f"grid_status(): Exception {type(e).__name__}: {e}")
960
+ raise
961
+
962
+ def system_status(self, jsonformat=False) -> Optional[Union[dict, str]]:
963
+ """
964
+ Get the full system status and basically do a straight passthrough return
965
+
966
+ Some data points of note are
967
+ nominal_full_pack_energy
968
+ nominal_energy_remaining
969
+ system_island_state - returns same value as grid_status
970
+ available_blocks - number of batteries
971
+ battery_blocks - array of batteries
972
+ PackageSerialNumber
973
+ disabled_reasons
974
+ nominal_energy_remaining
975
+ nominal_full_pack_energy
976
+ v_out - voltage out
977
+ f_out - frequency out
978
+ energy_charged
979
+ energy_discharged
980
+ backup_ready
981
+ grid_faults - array of faults
982
+
983
+ Args:
984
+ jsonformat = If True, return JSON format otherwise return Python Dictionary
985
+ """
986
+ payload: dict = self.poll('/api/system_status')
987
+ if payload is None:
988
+ return None
989
+
990
+ if jsonformat:
991
+ return json.dumps(payload, indent=4, sort_keys=True)
992
+ else:
993
+ return payload
994
+
995
+ def battery_blocks(self, jsonformat=False):
996
+ """
997
+ Get detailed information about each battery. If you want to get aggregate power information
998
+ on all the batteries, use battery()
999
+
1000
+ This function actually makes two API calls. The primary data is harvested from the
1001
+ battery_blocks section in /api/system_status but the temperature data is only
1002
+ available via /api/devices/vitals
1003
+
1004
+ Some data points of note are
1005
+ battery_blocks - array of batteries
1006
+ disabled_reasons
1007
+ nominal_energy_remaining
1008
+ nominal_full_pack_energy
1009
+ v_out - voltage out
1010
+ f_out - frequency out
1011
+ energy_charged
1012
+ energy_discharged
1013
+ backup_ready
1014
+ grid_faults - array of faults
1015
+
1016
+ Args:
1017
+ jsonformat = If True, return JSON format otherwise return Python Dictionary
1018
+ """
1019
+ system_status: dict = self.system_status()
1020
+ if system_status is None:
1021
+ return None
1022
+
1023
+ devices: dict = self.vitals() or {}
1024
+
1025
+ result = {}
1026
+ # copy the info from system_status into result
1027
+ # but change the key to the battery serial number
1028
+ # battery_blocks and PackageSerialNumber may be missing depending on
1029
+ # firmware/mode - guard with .get() instead of direct indexing
1030
+ for bat in system_status.get('battery_blocks') or []:
1031
+ sn = bat.get('PackageSerialNumber')
1032
+ bat_res = {}
1033
+ for j in bat:
1034
+ if j != 'PackageSerialNumber':
1035
+ bat_res[j] = bat[j]
1036
+ result[sn] = bat_res
1037
+
1038
+ # now merge in the "interesting" data from vitals
1039
+ # Right now we're just pulling in the temp and state from the TETHC block
1040
+ # There is also info in TPOD and TINV that could be associated with the battery.
1041
+ for device in devices:
1042
+ if device.startswith("TETHC--"):
1043
+ sn = device.split("--")[2]
1044
+ bat_res = {
1045
+ 'THC_State': devices[device].get('THC_State'),
1046
+ 'temperature': devices[device].get('THC_AmbientTemp')
1047
+ }
1048
+ # Some firmware or modes may return vitals entries before /api/system_status
1049
+ # includes corresponding battery_blocks. Guard against missing keys.
1050
+ if sn not in result:
1051
+ result[sn] = {}
1052
+ result[sn].update(bat_res)
1053
+
1054
+ if jsonformat:
1055
+ return json.dumps(result, indent=4, sort_keys=True)
1056
+ else:
1057
+ return result
1058
+
1059
+ def get_time_remaining(self) -> Optional[float]:
1060
+ """
1061
+ Get the backup time remaining on the battery
1062
+
1063
+ Returns:
1064
+ The time remaining in hours
1065
+ """
1066
+ if self._no_client():
1067
+ return None
1068
+ return self.client.get_time_remaining()
1069
+
1070
+ def set_grid_charging(self, mode) -> Optional[dict]:
1071
+ """
1072
+ Enable or disable grid charging
1073
+
1074
+ Args:
1075
+ mode: Set to True to enable grid charging, False to disable it
1076
+
1077
+ Returns:
1078
+ Dictionary with operation results.
1079
+ """
1080
+ if self._no_client():
1081
+ return None
1082
+ return self.client.set_grid_charging(mode)
1083
+
1084
+ def get_grid_charging(self) -> Optional[bool]:
1085
+ """
1086
+ Get the current grid charging mode
1087
+
1088
+ Note:
1089
+ This function requires FleetAPI or Cloud mode. It is not available
1090
+ in local TEDAPI mode. Returns None if not supported by the current mode.
1091
+
1092
+ Returns:
1093
+ True if grid charging is enabled, False if it is disabled, None if unsupported
1094
+ """
1095
+ if self._no_client():
1096
+ return None
1097
+ return self.client.get_grid_charging()
1098
+
1099
+ def set_grid_export(self, mode: str) -> Optional[dict]:
1100
+ """
1101
+ Set grid export mode
1102
+
1103
+ Args:
1104
+ mode: Set grid export mode (battery_ok, pv_only, or never)
1105
+
1106
+ Returns:
1107
+ Dictionary with operation results.
1108
+ """
1109
+ # Check for valid mode
1110
+ if mode not in ['battery_ok', 'pv_only', 'never']:
1111
+ raise ValueError(f"Invalid value for parameter 'mode': {mode}")
1112
+ if self._no_client():
1113
+ return None
1114
+ return self.client.set_grid_export(mode)
1115
+
1116
+ def get_grid_export(self) -> Optional[str]:
1117
+ """
1118
+ Get the current grid export mode
1119
+
1120
+ Note:
1121
+ This function requires FleetAPI or Cloud mode. It is not available
1122
+ in local TEDAPI mode. Returns None if not supported by the current mode.
1123
+
1124
+ Returns:
1125
+ The current grid export mode, or None if unsupported
1126
+ """
1127
+ if self._no_client():
1128
+ return None
1129
+ return self.client.get_grid_export()
1130
+
1131
+ def go_off_grid(self, confirm: bool = False) -> Optional[dict]:
1132
+ """
1133
+ Physically disconnect the Powerwall from the grid (open contactor).
1134
+
1135
+ USE WITH EXTREME CARE: this operates the physical grid contactor.
1136
+ While islanded, the home runs on battery + solar alone — a depleted
1137
+ battery means the home loses power until reconnect_grid() succeeds.
1138
+ Do not automate without handling those failure modes; verify grid
1139
+ status after the command rather than assuming success.
1140
+
1141
+ Sends setIslandModeRequest(mode=6, force=True) which physically
1142
+ opens the grid contactor, islanding the home. Solar continues
1143
+ producing and the battery serves home load. Causes ~30s solar
1144
+ dropout during the contactor transition.
1145
+
1146
+ Note:
1147
+ This operation requires explicit confirmation. Pass
1148
+ confirm=True to send the command.
1149
+
1150
+ On both Powerwall 2 and Powerwall 3, this requires a signed
1151
+ command: either a signed RoutableMessage via the cloud
1152
+ device_command endpoint's routable_message field, or — when a
1153
+ v1r RSA key is paired — Tesla's signed setIslandMode command
1154
+ sent locally over the TEDAPI v1r transport. The unsigned
1155
+ grpc_command path is
1156
+ accepted by the gateway but does not physically operate the
1157
+ contactor. The local REST endpoint /api/v2/islanding/mode
1158
+ requires installer-level auth and is not usable with the
1159
+ customer login role.
1160
+
1161
+ force=True is required — without it the gateway acknowledges
1162
+ the command but does not open the contactor.
1163
+
1164
+ Returns:
1165
+ Dictionary with operation results, or None if unsupported.
1166
+ """
1167
+ if not confirm:
1168
+ log.error("go_off_grid requires confirm=True")
1169
+ return None
1170
+ if hasattr(self.client, 'go_off_grid'):
1171
+ return self.client.go_off_grid()
1172
+ log.error("go_off_grid is not supported by %s backend", type(self.client).__name__)
1173
+ return None
1174
+
1175
+ def reconnect_grid(self) -> Optional[dict]:
1176
+ """
1177
+ Reconnect the Powerwall to the grid (close contactor).
1178
+
1179
+ USE WITH EXTREME CARE: this operates the physical grid contactor.
1180
+ Verify grid status after the command rather than assuming success.
1181
+
1182
+ Sends setIslandModeRequest(mode=1) which physically closes the
1183
+ grid contactor, reconnecting the home to the utility grid.
1184
+
1185
+ Returns:
1186
+ Dictionary with operation results, or None if unsupported.
1187
+ """
1188
+ if hasattr(self.client, 'reconnect_grid'):
1189
+ return self.client.reconnect_grid()
1190
+ log.error("reconnect_grid is not supported by %s backend", type(self.client).__name__)
1191
+ return None
1192
+
1193
+ def _validate_init_configuration(self):
1194
+
1195
+ # Basic user input validation for starters. Can be expanded to limit other parameters such as cache
1196
+ # expiration range, timeout range, etc
1197
+
1198
+ # Check for valid hostname/IP address.
1199
+ # Supports optional :port suffix (e.g. "192.168.1.50:8443") — strip the port
1200
+ # before regex validation since IPV4_6_REGEX and HOST_REGEX only match bare hosts.
1201
+ #
1202
+ # IMPORTANT: only attempt port-stripping when the value does NOT already validate
1203
+ # as a bare host/IP. This prevents false port detection in IPv6 addresses, where
1204
+ # colons are part of the address itself (e.g. "2001:db8::1" must not have its
1205
+ # trailing ":1" mistaken for a port number).
1206
+ if self.host:
1207
+ host_part = self.host
1208
+ if (
1209
+ isinstance(host_part, str) and
1210
+ not IPV4_6_REGEX.match(host_part) and
1211
+ not HOST_REGEX.match(host_part)
1212
+ ):
1213
+ # Not a bare host — check if a trailing :port suffix can be stripped.
1214
+ # Only consider single-colon strings (hostname:port or IPv4:port);
1215
+ # multi-colon strings are IPv6 or malformed and must not be treated as host:port.
1216
+ if host_part.count(":") == 1:
1217
+ last_part = host_part.rsplit(":", 1)[1]
1218
+ if last_part.isdigit():
1219
+ port = int(last_part)
1220
+ if not (1 <= port <= 65535):
1221
+ raise PyPowerwallInvalidConfigurationParameter(
1222
+ f"Invalid port in host '{self.host}': port {port} is out of the "
1223
+ f"valid TCP range 1-65535.")
1224
+ host_part = host_part.rsplit(":", 1)[0]
1225
+ if (
1226
+ not isinstance(host_part, str) or
1227
+ (not IPV4_6_REGEX.match(host_part) and not HOST_REGEX.match(host_part))
1228
+ ):
1229
+ raise PyPowerwallInvalidConfigurationParameter(f"Invalid powerwall host: '{self.host}'. Must be in the "
1230
+ f"form of IP address or a valid form of a hostname or FQDN.")
1231
+ # TODO: IPv6 support - bare IPv6 addresses (e.g. "2001:db8::1") pass validation via
1232
+ # IPV4_6_REGEX but URL construction produces malformed URLs. If IPv6 is ever needed,
1233
+ # detect a validated bare IPv6 host here and wrap it in brackets so that all downstream
1234
+ # callers automatically produce RFC 2732-compliant URLs (e.g. "https://[2001:db8::1]/...").
1235
+
1236
+ # If cloud mode requested, check appropriate parameters
1237
+ if self.cloudmode and not self.fleetapi:
1238
+ # Ensure email is set and syntactically correct
1239
+ if not self.email or not isinstance(self.email, str) or not EMAIL_REGEX.match(self.email):
1240
+ raise PyPowerwallInvalidConfigurationParameter(f"A valid email address is required to run in "
1241
+ f"cloud mode: '{self.email}' did not pass validation.")
1242
+
1243
+ # Ensure we can write to the provided authpath
1244
+ dirname = self.authpath
1245
+ if not dirname:
1246
+ log.debug("No authpath provided, using current directory.")
1247
+ dirname = '.'
1248
+ self._check_if_dir_is_writable(dirname, "authpath")
1249
+ elif self.fleetapi:
1250
+ # Ensure we can write to the configfile (or its directory if it doesn't exist yet)
1251
+ self.configfile = os.path.join(self.authpath, CONFIGFILE)
1252
+ if os.path.exists(self.configfile):
1253
+ if os.access(self.configfile, os.W_OK):
1254
+ log.debug(f"Config file '{self.configfile}' is writable.")
1255
+ else:
1256
+ raise PyPowerwallInvalidConfigurationParameter(f"Config file '{self.configfile}' is not writable.")
1257
+ else:
1258
+ dirname = self.authpath or '.'
1259
+ self._check_if_dir_is_writable(dirname, "authpath")
1260
+ else:
1261
+ # If local mode, check appropriate parameters, and ensure we can write to the provided cachefile
1262
+ dirname = os.path.dirname(self.cachefile)
1263
+ if not dirname:
1264
+ log.debug("No cachefile provided, using current directory.")
1265
+ dirname = '.'
1266
+ self._check_if_dir_is_writable(dirname, "cachefile")
1267
+
1268
+ @staticmethod
1269
+ def _check_if_dir_is_writable(dirpath, name=""):
1270
+ # Ensure we can write to the provided authpath
1271
+ if not os.path.exists(dirpath):
1272
+ try:
1273
+ os.makedirs(dirpath, exist_ok=True)
1274
+ except Exception as exc:
1275
+ raise PyPowerwallInvalidConfigurationParameter(f"Unable to create {name} directory at "
1276
+ f"'{dirpath}': {exc}") from exc
1277
+ elif not os.path.isdir(dirpath):
1278
+ raise PyPowerwallInvalidConfigurationParameter(f"'{dirpath}' must be a directory ({name}).")
1279
+ else:
1280
+ if not os.access(dirpath, os.W_OK):
1281
+ raise PyPowerwallInvalidConfigurationParameter(f"Directory '{dirpath}' is not writable for {name}. "
1282
+ f"Check permissions.")