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.
- pypowerwall/__init__.py +1282 -0
- pypowerwall/__main__.py +932 -0
- pypowerwall/api_lock.py +72 -0
- pypowerwall/cloud/__init__.py +0 -0
- pypowerwall/cloud/decorators.py +10 -0
- pypowerwall/cloud/exceptions.py +14 -0
- pypowerwall/cloud/mock_data.py +179 -0
- pypowerwall/cloud/pypowerwall_cloud.py +1303 -0
- pypowerwall/cloud/stubs.py +131 -0
- pypowerwall/cloud/teslapy/__init__.py +1282 -0
- pypowerwall/cloud/teslapy/endpoints.json +3121 -0
- pypowerwall/cloud/teslapy/option_codes.json +696 -0
- pypowerwall/exceptions.py +13 -0
- pypowerwall/fleetapi/__init__.py +0 -0
- pypowerwall/fleetapi/__main__.py +218 -0
- pypowerwall/fleetapi/decorators.py +10 -0
- pypowerwall/fleetapi/exceptions.py +14 -0
- pypowerwall/fleetapi/fleetapi.py +1101 -0
- pypowerwall/fleetapi/mock_data.py +179 -0
- pypowerwall/fleetapi/pypowerwall_fleetapi.py +815 -0
- pypowerwall/fleetapi/stubs.py +131 -0
- pypowerwall/helpers.py +64 -0
- pypowerwall/local/__init__.py +0 -0
- pypowerwall/local/exceptions.py +6 -0
- pypowerwall/local/pypowerwall_local.py +500 -0
- pypowerwall/local/tesla_pb2.py +222 -0
- pypowerwall/pypowerwall_base.py +95 -0
- pypowerwall/regex.py +22 -0
- pypowerwall/scan.py +309 -0
- pypowerwall/tedapi/__init__.py +2909 -0
- pypowerwall/tedapi/__main__.py +225 -0
- pypowerwall/tedapi/api_version.py +161 -0
- pypowerwall/tedapi/auth_mode.py +49 -0
- pypowerwall/tedapi/decorators.py +10 -0
- pypowerwall/tedapi/enums.py +66 -0
- pypowerwall/tedapi/exceptions.py +14 -0
- pypowerwall/tedapi/mock_data.py +179 -0
- pypowerwall/tedapi/protobuf/V2024_06/__init__.py +1 -0
- pypowerwall/tedapi/protobuf/V2024_06/tedapi_combined_pb2.py +123 -0
- pypowerwall/tedapi/protobuf/V2024_06/tedapi_pb2.py +64 -0
- pypowerwall/tedapi/protobuf/V2026_06/__init__.py +1 -0
- pypowerwall/tedapi/protobuf/V2026_06/tedapi_v2_common_pb2.py +36 -0
- pypowerwall/tedapi/protobuf/V2026_06/tedapi_v2_energy_device_pb2.py +385 -0
- pypowerwall/tedapi/protobuf/V2026_06/tedapi_v2_energy_registration_pb2.py +87 -0
- pypowerwall/tedapi/protobuf/V2026_06/tedapi_v2_google_rpc_pb2.py +39 -0
- pypowerwall/tedapi/protobuf/V2026_06/tedapi_v2_transport_pb2.py +39 -0
- pypowerwall/tedapi/protobuf/__init__.py +1 -0
- pypowerwall/tedapi/pypowerwall_tedapi.py +1029 -0
- pypowerwall/tedapi/queries/V2024_06.json +23 -0
- pypowerwall/tedapi/queries/V2026_06.json +72 -0
- pypowerwall/tedapi/queries/__init__.py +133 -0
- pypowerwall/tedapi/queries/base.py +31 -0
- pypowerwall/tedapi/stubs.py +131 -0
- pypowerwall/tedapi/system_info.py +158 -0
- pypowerwall/tedapi/tedapi_combined_pb2.py +7 -0
- pypowerwall/tedapi/tedapi_pb2.py +6 -0
- pypowerwall/tedapi/tedapi_v1r.py +606 -0
- pypowerwall/tesla_auth.py +1233 -0
- pypowerwall/tests/__init__.py +0 -0
- pypowerwall/tests/conftest.py +56 -0
- pypowerwall/tests/tedapi/__init__.py +0 -0
- pypowerwall/tests/tedapi/test_api_version.py +580 -0
- pypowerwall/tests/tedapi/test_init.py +369 -0
- pypowerwall/tests/tedapi/test_pw3_temperatures.py +314 -0
- pypowerwall/tests/tedapi/test_system_info.py +332 -0
- pypowerwall/tests/test_live_modes.py +152 -0
- pypowerwall/tests/test_mode_selection.py +286 -0
- pypowerwall/tests/test_powerwall.py +150 -0
- pypowerwall/tests/unit/__init__.py +0 -0
- pypowerwall/tests/unit/test_bearer_auth.py +790 -0
- pypowerwall/tests/unit/test_bearer_wiring.py +229 -0
- pypowerwall/tests/unit/test_cli_tedapi.py +147 -0
- pypowerwall/tests/unit/test_cloud_regressions.py +176 -0
- pypowerwall/tests/unit/test_credential_file_permissions.py +51 -0
- pypowerwall/tests/unit/test_facade_no_client.py +64 -0
- pypowerwall/tests/unit/test_fleetapi_operation_payload.py +53 -0
- pypowerwall/tests/unit/test_fleetapi_regressions.py +195 -0
- pypowerwall/tests/unit/test_fleetapi_token.py +86 -0
- pypowerwall/tests/unit/test_local_regressions.py +125 -0
- pypowerwall/tests/unit/test_native_energy_merge.py +295 -0
- pypowerwall/tests/unit/test_packaging.py +26 -0
- pypowerwall/tests/unit/test_parse_version.py +28 -0
- pypowerwall/tests/unit/test_powerwall_core.py +417 -0
- pypowerwall/tests/unit/test_tedapi_cached_fetch.py +742 -0
- pypowerwall/tests/unit/test_tedapi_regressions.py +369 -0
- pypowerwall/tests/unit/test_tedapi_remote_meter.py +646 -0
- pypowerwall/tests/unit/test_v1r_islanding.py +77 -0
- pypowerwall/tests/unit/test_v1r_register.py +133 -0
- pypowerwall/tests/unit/test_v1r_warnings.py +346 -0
- pypowerwall/tests/unit/test_version.py +48 -0
- pypowerwall/v1r_register.py +810 -0
- pypowerwall-0.18.0.dist-info/METADATA +1282 -0
- pypowerwall-0.18.0.dist-info/RECORD +97 -0
- pypowerwall-0.18.0.dist-info/WHEEL +5 -0
- pypowerwall-0.18.0.dist-info/entry_points.txt +2 -0
- pypowerwall-0.18.0.dist-info/licenses/LICENSE +21 -0
- pypowerwall-0.18.0.dist-info/top_level.txt +1 -0
pypowerwall/__init__.py
ADDED
|
@@ -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.")
|