From 418a26f235e506695b6cf4e277e351196d64525d Mon Sep 17 00:00:00 2001 From: Igor Cherkaev Date: Tue, 26 Mar 2024 18:16:33 -0500 Subject: [PATCH 01/25] ## v0.8.1 - Set battery reserve, operation mode * Added `get_mode()` function. * Added `set_battery_op_reserve()` function to set battery operation mode and/or reserve level. Likely won't work in the local mode. * Added basic validation for main class `__init__()` parameters (a.k.a. user input). --- README.md | 4 + RELEASE.md | 14 +- api_test.py | 235 ++++++++++++++++--------- example-cloud-mode.py | 1 + example.py | 1 + pypowerwall/__init__.py | 142 ++++++++++++++- pypowerwall/aux.py | 22 +++ pypowerwall/cloud/exceptions.py | 4 + pypowerwall/cloud/pypowerwall_cloud.py | 76 ++++++++ pypowerwall/exceptions.py | 6 + pypowerwall/local/pypowerwall_local.py | 61 ++++++- pypowerwall/pypowerwall_base.py | 5 + 12 files changed, 470 insertions(+), 101 deletions(-) create mode 100644 pypowerwall/aux.py create mode 100644 pypowerwall/exceptions.py diff --git a/README.md b/README.md index 3bf58b7..5631df2 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,7 @@ and call function to poll data. Here is an example: Functions poll(api, json, force) # Return data from Powerwall api (dict if json=True, bypass cache force=True) + post(api, payload, json) # Send payload to Powerwall api (dict if json=True) level() # Return battery power level percentage power() # Return power data returned as dictionary site(verbose) # Return site sensor data (W or raw JSON if verbose=True) @@ -167,7 +168,10 @@ and call function to poll data. Here is an example: # - "numeric": -1 (Syncing), 0 (DOWN), 1 (UP) is_connected() # Returns True if able to connect to Powerwall get_reserve(scale) # Get Battery Reserve Percentage + get_mode() # Get Current Battery Operation Mode get_time_remaining() # Get the backup time remaining on the battery + + set_battery_op_reserve(level, mode, json) # Set Battery Reserve Percentage and/or Operation Mode ``` diff --git a/RELEASE.md b/RELEASE.md index b1bfe03..bba80f9 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,12 +1,18 @@ # RELEASE NOTES +## v0.8.1 - Set battery reserve, operation mode + +* Added `get_mode()` function. +* Added `set_battery_op_reserve()` function to set battery operation mode and/or reserve level. Likely won't work in the local mode. +* Added basic validation for main class `__init__()` parameters (a.k.a. user input). + ## v0.8.0 - Refactoring * Refactored pyPowerwall by @emptywee in https://github.com/jasonacox/pypowerwall/pull/77 including: -* Moved Local and Cloud based operation code into respective modules, providing better abstraction and making it easier to maintain and extend going forward. -* Made meaning of the `jsonformat` parameter consistent across all method calls (breaking API change). -* Removed Python 2.7 support. -* Cleaned up code and adopted a more pythoinc style. + * Moved Local and Cloud based operation code into respective modules, providing better abstraction and making it easier to maintain and extend going forward. + * Made meaning of the `jsonformat` parameter consistent across all method calls (breaking API change). + * Removed Python 2.7 support. + * Cleaned up code and adopted a more pythoinc style. * Fixed battery_blocks() for non-vitals systems. ## v0.7.12 - Cachefile, Alerts & Strings diff --git a/api_test.py b/api_test.py index fa22549..219749d 100644 --- a/api_test.py +++ b/api_test.py @@ -1,4 +1,6 @@ # Test Functions of the Powerwall API +import os +import time import pypowerwall @@ -6,96 +8,155 @@ pypowerwall.set_debug(True) # Credentials for your Powerwall - Customer Login Data -password='local_password' -email='name@example.com' -host = "localhost" # e.g. 10.0.1.123 -timezone = "America/Los_Angeles" # https://en.wikipedia.org/wiki/List_of_tz_database_time_zones - -for h in [host, ""]: - if h: - print(f"LOCAL MODE: Connecting to Powerwall at {h}") +password = os.environ.get('PW_PASSWORD', 'password') +email = os.environ.get('PW_EMAIL', 'email@example.com') +host = os.environ.get('PW_HOST', 'localhost') # Change to the IP of your Powerwall +timezone = os.environ.get('PW_TIMEZONE', + 'America/Los_Angeles') # https://en.wikipedia.org/wiki/List_of_tz_database_time_zones +auth_path = os.environ.get('PW_AUTH_PATH', "") +cachefile_path = os.environ.get('PW_CACHEFILE_PATH', ".cachefile") + + +def test_battery_mode_change(pw): + original_mode = pw.get_mode(force=True) + if original_mode != 'backup': + new_mode = 'backup' else: - print(f"CLOUD MODE: Connecting to Powerwall via Tesla API") - print("---------------------------------------------------------") - - # Connect to Powerwall - pw = pypowerwall.Powerwall(h,password,email,timezone) - - aggregates = pw.poll('/api/meters/aggregates') - coe = pw.poll('/api/system_status/soe') - - # Pull Sensor Power Data - grid = pw.grid() - solar = pw.solar() - battery = pw.battery() - home = pw.home() - - # Display Data - battery_level = pw.level() - combined_power_metrics = pw.power() - print(f"Battery power level: \033[92m{battery_level:.0f}%\033[0m") - print(f"Combined power metrics: \033[92m{combined_power_metrics}\033[0m") - print("") + new_mode = 'self_consumption' - # Display Power in kW - print(f"Grid Power: \033[92m{float(grid)/1000.0:.2f}kW\033[0m") - print(f"Solar Power: \033[92m{float(solar)/1000.0:.2f}kW\033[0m") - print(f"Battery Power: \033[92m{float(battery)/1000.0:.2f}kW\033[0m") - print(f"Home Power: \033[92m{float(home)/1000.0:.2f}kW\033[0m") - print() - - # Raw JSON Payload Examples - print(f"Grid raw: \033[92m{pw.grid(verbose=True)!r}\033[0m\n") - print(f"Solar raw: \033[92m{pw.solar(verbose=True)!r}\033[0m\n") - - # Test each function - print("Testing each function:") - functions = [ - pw.poll, - pw.level, - pw.power, - pw.site, - pw.solar, - pw.battery, - pw.load, - pw.grid, - pw.home, - pw.vitals, - pw.strings, - pw.din, - pw.uptime, - pw.version, - pw.status, - pw.site_name, - pw.temps, - pw.alerts, - pw.system_status, - pw.battery_blocks, - pw.grid_status, - pw.is_connected, - pw.get_reserve, - pw.get_time_remaining - ] - for func in functions: - print(f"{func.__name__}()") - func() - - print("All functions tested.") - print("") - print("Testing all functions and printing result:") - input("Press Enter to continue...") - print("") + resp = pw.set_battery_op_reserve(mode=new_mode) + if resp and resp.get('set_operation', {}).get('result') == 'Updated': + # if we got a valid response from API, let's assume it worked :) + installed_mode = resp.get('set_operation', {}).get('real_mode') + else: + # TODO: may need to poll API until change is detected (in cloud mode) + installed_mode = pw.get_mode(force=True) + if installed_mode != new_mode: + print(f"Set battery operation mode to {new_mode} failed.") + # revert to original value just in case + pw.set_battery_op_reserve(mode=original_mode) - for func in functions: - print(f"{func.__name__}()") - print("\033[92m", end="") - print(func()) - print("\033[0m") - print("All functions tested.") - print("") - input("Press Enter to continue...") +def test_battery_reserve_change(pw): + original_reserve_level = pw.get_reserve(force=True) + if original_reserve_level != 100: + new_reserve_level = 100 + else: + new_reserve_level = 50 + + resp = pw.set_battery_op_reserve(level=new_reserve_level) + if resp and resp.get('set_backup_reserve_percent', {}).get('result') == 'Updated': + # if we got a valid response from API, let's assume it worked :) + installed_level = resp.get('set_backup_reserve_percent', {}).get('backup_reserve_percent') + else: + # TODO: may need to poll API until change is detected (in cloud mode) + installed_level = pw.get_reserve(force=True) + if installed_level != new_reserve_level: + print(f"Set battery reserve level to {new_reserve_level}% failed.") + # revert to original value just in case + pw.set_battery_op_reserve(level=original_reserve_level) + + +def test_post_functions(pw): + # test battery reserve and mode change + test_battery_mode_change(pw) + test_battery_reserve_change(pw) + + +def run(include_post_funcs=False): + for h in [host, ""]: + if h: + print(f"LOCAL MODE: Connecting to Powerwall at {h}") + else: + print(f"CLOUD MODE: Connecting to Powerwall via Tesla API") + print("---------------------------------------------------------") + + # Connect to Powerwall + pw = pypowerwall.Powerwall(h, password, email, timezone, authpath=auth_path, cachefile=cachefile_path) + + aggregates = pw.poll('/api/meters/aggregates') + coe = pw.poll('/api/system_status/soe') + + # Pull Sensor Power Data + grid = pw.grid() + solar = pw.solar() + battery = pw.battery() + home = pw.home() + + # Display Data + battery_level = pw.level() + combined_power_metrics = pw.power() + print(f"Battery power level: \033[92m{battery_level:.0f}%\033[0m") + print(f"Combined power metrics: \033[92m{combined_power_metrics}\033[0m") + print("") + + # Display Power in kW + print(f"Grid Power: \033[92m{float(grid) / 1000.0:.2f}kW\033[0m") + print(f"Solar Power: \033[92m{float(solar) / 1000.0:.2f}kW\033[0m") + print(f"Battery Power: \033[92m{float(battery) / 1000.0:.2f}kW\033[0m") + print(f"Home Power: \033[92m{float(home) / 1000.0:.2f}kW\033[0m") + print() + + # Raw JSON Payload Examples + print(f"Grid raw: \033[92m{pw.grid(verbose=True)!r}\033[0m\n") + print(f"Solar raw: \033[92m{pw.solar(verbose=True)!r}\033[0m\n") + + # Test each function + print("Testing each function:") + functions = [ + pw.poll, + pw.level, + pw.power, + pw.site, + pw.solar, + pw.battery, + pw.load, + pw.grid, + pw.home, + pw.vitals, + pw.strings, + pw.din, + pw.uptime, + pw.version, + pw.status, + pw.site_name, + pw.temps, + pw.alerts, + pw.system_status, + pw.battery_blocks, + pw.grid_status, + pw.is_connected, + pw.get_reserve, + pw.get_mode, + pw.get_time_remaining + ] + for func in functions: + print(f"{func.__name__}()") + print(f"{func()}") + + if include_post_funcs: + test_post_functions(pw) + + print("All functions tested.") + print("") + print("Testing all functions and printing result:") + input("Press Enter to continue...") + print("") + + for func in functions: + print(f"{func.__name__}()") + print("\033[92m", end="") + print(func()) + print("\033[0m") + + print("All functions tested.") + print("") + input("Press Enter to continue...") + print("") + + print("All tests completed.") print("") -print("All tests completed.") -print("") \ No newline at end of file + +if __name__ == "__main__": + run(include_post_funcs=False) diff --git a/example-cloud-mode.py b/example-cloud-mode.py index 18fb430..312df70 100644 --- a/example-cloud-mode.py +++ b/example-cloud-mode.py @@ -24,5 +24,6 @@ print("Home Power: %0.2fkW" % (float(pw.home()) / 1000.0)) # Raw JSON Data Examples + print("Status: %s" % pw.status()) print("Grid raw: %r" % pw.grid(verbose=True)) print("Solar raw: %r" % pw.solar(verbose=True)) diff --git a/example.py b/example.py index 08cc294..c30dbed 100644 --- a/example.py +++ b/example.py @@ -25,6 +25,7 @@ print("Home Power: %0.2fkW" % (float(pw.home()) / 1000.0)) # Raw JSON Data Examples + print("Status: %s" % pw.status()) print("Grid raw: %r" % pw.grid(verbose=True)) print("Solar raw: %r" % pw.solar(verbose=True)) print("Strings raw: %r" % pw.strings(verbose=True, jsonformat=True)) diff --git a/pypowerwall/__init__.py b/pypowerwall/__init__.py index 22e8f57..d13e9da 100644 --- a/pypowerwall/__init__.py +++ b/pypowerwall/__init__.py @@ -36,6 +36,7 @@ Functions poll(api, json, force) # Return data from Powerwall api (dict if json=True, bypass cache force=True) + post(api, payload, json) # Send payload to Powerwall api (dict if json=True) level() # Return battery power level percentage power() # Return power data returned as dictionary site(verbose) # Return site sensor data (W or raw JSON if verbose=True) @@ -60,8 +61,11 @@ # - "numeric": -1 (Syncing), 0 (DOWN), 1 (UP) is_connected() # Returns True if able to connect and login to Powerwall get_reserve(scale) # Get Battery Reserve Percentage + get_mode() # Get Current Battery Operation Mode get_time_remaining() # Get the backup time remaining on the battery + set_battery_op_reserve(level, mode, json) # Set Battery Reserve Percentage and/or Operation Mode + Requirements This module requires the following modules: requests, protobuf, teslapy pip install requests protobuf teslapy @@ -70,18 +74,21 @@ import logging import os.path import sys +from json import JSONDecodeError from typing import Union, Optional # noinspection PyPackageRequirements import urllib3 +from pypowerwall.aux import HOST_REGEX, IPV4_6_REGEX, EMAIL_REGEX +from pypowerwall.exceptions import PyPowerwallInvalidConfigurationParameter, InvalidBatteryReserveLevelException from pypowerwall.cloud.pypowerwall_cloud import PyPowerwallCloud from pypowerwall.local.pypowerwall_local import PyPowerwallLocal from pypowerwall.pypowerwall_base import parse_version, PyPowerwallBase urllib3.disable_warnings() # Disable SSL warnings -version_tuple = (0, 8, 0) +version_tuple = (0, 8, 1) version = __version__ = '%d.%d.%d' % version_tuple __author__ = 'jasonacox' @@ -146,13 +153,19 @@ def __init__(self, host="", password="", email="nobody@nowhere.com", self.pwcooldown = 0 # rate limit cooldown time - pause api calls self.vitals_api = True # vitals api is available for local mode self.client: PyPowerwallBase - # Check for cloud mode - if self.cloudmode or self.host == "": + + # Make certain assumptions here + if not self.host: self.cloudmode = True + + # Validate provided parameters + self._validate_init_configuration() + + # Check for cloud mode + if self.cloudmode: self.client = PyPowerwallCloud(self.email, self.pwcacheexpire, self.timeout, self.siteid, self.authpath) # Check to see if we can connect to the cloud else: - self.cloudmode = False self.client = PyPowerwallLocal(self.host, self.password, self.email, self.timezone, self.timeout, self.pwcacheexpire, self.poolmaxsize, self.authmode, self.cachefile) @@ -173,7 +186,7 @@ def is_connected(self): return False def poll(self, api='/api/site_info/site_name', jsonformat=False, raw=False, recursive=False, - force=False) -> Union[dict, str]: + force=False) -> Optional[Union[dict, list, str, bytes]]: """ Query Tesla Energy Gateway Powerwall for API Response @@ -184,13 +197,36 @@ def poll(self, api='/api/site_info/site_name', jsonformat=False, raw=False, recu recursive = If True, this is a recursive call and do not allow additional recursive calls force = If True, bypass the cache and make the API call to the gateway, has no meaning in Cloud mode """ - # Check to see if we are in cloud mode payload = self.client.poll(api, force, recursive, raw) if jsonformat: - return json.dumps(payload) + try: + return json.dumps(payload) + except JSONDecodeError: + log.error(f"Unable to dump response '{payload}' as JSON. I know you asked for it, sorry.") else: return payload + def post(self, api: str, payload: Optional[dict], din: Optional[str] = None, jsonformat=False, raw=False, + recursive=False) -> Optional[Union[dict, list, str, bytes]]: + """ + Send a command to Tesla Energy Gateway + + Args: + api = URI + payload = Arbitrary payload to send + din = System DIN (ignored in Cloud mode) + raw = If True, send raw data back (useful for binary responses, has no meaning in Cloud mode) + recursive = If True, this is a recursive call and do not allow additional recursive calls + """ + response = self.client.post(api, payload, din, recursive, raw) + if jsonformat: + try: + return json.dumps(response) + except JSONDecodeError: + log.error(f"Unable to dump response '{response}' as JSON. I know you asked for it, sorry.") + else: + return response + def level(self, scale=False): """ Battery Level Percentage @@ -458,7 +494,7 @@ def alerts(self, jsonformat=False, alertsonly=True) -> Union[list, str]: else: return alerts - def get_reserve(self, scale=True) -> Optional[float]: + def get_reserve(self, scale=True, force=False) -> Optional[float]: """ Get Battery Reserve Percentage Tesla App reserves 5% of battery => ( (batterylevel / 0.95) - (5 / 0.95) ) @@ -466,7 +502,7 @@ def get_reserve(self, scale=True) -> Optional[float]: Args: scale = If True (default) use Tesla's 5% reserve calculation """ - data = self.poll('/api/operation') + data = self.poll('/api/operation', force=force) if data is not None and 'backup_reserve_percent' in data: percent = float(data['backup_reserve_percent']) if scale: @@ -475,6 +511,45 @@ def get_reserve(self, scale=True) -> Optional[float]: return percent return None + def get_mode(self, force=False) -> Optional[float]: + """ + Get Battery Operation Mode + """ + data = self.poll('/api/operation', force=force) + if data and data.get('real_mode'): + return data['real_mode'] + return None + + def set_battery_op_reserve(self, level: Optional[float] = None, mode: Optional[str] = None, + jsonformat: bool = False) -> Optional[Union[dict, str]]: + """ + Set battery operation mode and reserve level. + + Args: + level: Set battery reserve level in percents (range of 5-100 is accepted) + mode: Set battery operation mode (self_consumption, backup, autonomous, etc.) + jsonformat: Set to True to receive json formatted string + + Returns: + Dictionary with operation results, if jsonformat is False, else a JSON string + """ + if level and (level < 5 or level > 100): + raise InvalidBatteryReserveLevelException('Level can be in range of 5 to 100 only.') + + if not level: + level = self.get_reserve() + + if not mode: + mode = self.get_mode() + + payload = { + 'backup_reserve_percent': level, + 'real_mode': mode + } + + result = self.post(api='/api/operation', payload=payload, din=self.din(), jsonformat=jsonformat) + return result + # noinspection PyShadowingBuiltins def grid_status(self, type="string") -> Optional[Union[str, int]]: """ @@ -606,3 +681,52 @@ def get_time_remaining(self) -> Optional[float]: The time remaining in hours """ return self.client.get_time_remaining() + + def _validate_init_configuration(self): + + # Basic user input validation for starters. Can be expanded to limit other parameters such as cache + # expiration range, timeout range, etc + + # Check for valid hostname/IP address + if (self.host and ( + not isinstance(self.host, str) or + (not IPV4_6_REGEX.match(self.host) and not HOST_REGEX.match(self.host)) + )): + raise PyPowerwallInvalidConfigurationParameter(f"Invalid powerwall host: '{self.host}'. Must be in the " + f"form of IP address or a valid form of a hostname or FQDN.") + + # If cloud mode requested, check appropriate parameters + if self.cloudmode: + # Ensure email is set and syntactically correct + if not self.email or not isinstance(self.email, str) or not EMAIL_REGEX.match(self.email): + raise PyPowerwallInvalidConfigurationParameter(f"A valid email address is required to run in " + f"cloud mode: '{self.email}' did not pass validation.") + + # Ensure we can write to the provided authpath + dirname = self.authpath + if not dirname: + dirname = '.' + self._check_if_dir_is_writable(dirname) + # If local mode, check appropriate parameters, too + else: + # Ensure we can create a cachefile + dirname = os.path.dirname(self.cachefile) + if not dirname: + dirname = '.' + self._check_if_dir_is_writable(dirname) + + @staticmethod + def _check_if_dir_is_writable(dirpath): + # Ensure we can write to the provided authpath + if not os.path.exists(dirpath): + try: + os.makedirs(dirpath, exist_ok=True) + except Exception as exc: + raise PyPowerwallInvalidConfigurationParameter(f"Unable to create directory at " + f"'{dirpath}': {exc}") + elif not os.path.isdir(dirpath): + raise PyPowerwallInvalidConfigurationParameter(f"'{dirpath}' must be a directory.") + else: + if not os.access(dirpath, os.W_OK): + raise PyPowerwallInvalidConfigurationParameter(f"Directory '{dirpath}' is not writable. " + f"Check permissions.") diff --git a/pypowerwall/aux.py b/pypowerwall/aux.py new file mode 100644 index 0000000..e565319 --- /dev/null +++ b/pypowerwall/aux.py @@ -0,0 +1,22 @@ +import re + +IPV4_6_REGEX = re.compile(r'((^\s*((([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{' + r'2}|2[0-4][0-9]|25[0-5]))\s*$)|(^\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([' + r'0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[' + r'0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,' + r'2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([' + r'0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[' + r'0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,' + r'4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[' + r'1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[' + r'0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[' + r'0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,' + r'6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[' + r'0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,' + r'5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(' + r'%.+)?\s*$))') + +HOST_REGEX = re.compile(r'^(?=.{1,255}$)[0-9A-Za-z](?:(?:[0-9A-Za-z]|-){0,61}[0-9A-Za-z])?(?:\.[0-9A-Za-z](?:(?:[' + r'0-9A-Za-z]|-){0,61}[0-9A-Za-z])?)*\.?$') + +EMAIL_REGEX = re.compile(r'^\S+@\S+\.\S+$') diff --git a/pypowerwall/cloud/exceptions.py b/pypowerwall/cloud/exceptions.py index be121a6..f748b81 100644 --- a/pypowerwall/cloud/exceptions.py +++ b/pypowerwall/cloud/exceptions.py @@ -8,3 +8,7 @@ class PyPowerwallCloudTeslaNotConnected(Exception): class PyPowerwallCloudNotImplemented(Exception): pass + + +class PyPowerwallCloudInvalidPayload(Exception): + pass diff --git a/pypowerwall/cloud/pypowerwall_cloud.py b/pypowerwall/cloud/pypowerwall_cloud.py index dc32beb..ea0bc07 100644 --- a/pypowerwall/cloud/pypowerwall_cloud.py +++ b/pypowerwall/cloud/pypowerwall_cloud.py @@ -65,6 +65,7 @@ def __init__(self, email: Optional[str], pwcacheexpire: int = 5, timeout: int = self.authfile = os.path.join(self.authpath, AUTHFILE) self.sitefile = os.path.join(self.authpath, SITEFILE) self.poll_api_map = self.init_poll_api_map() + self.post_api_map = self.init_post_api_map() if self.siteid is None: # Check for site file @@ -79,6 +80,11 @@ def __init__(self, email: Optional[str], pwcacheexpire: int = 5, timeout: int = self.siteindex = 0 log.debug(f" -- cloud: Using site {self.siteid} for {self.email}") + def init_post_api_map(self) -> dict: + return { + "/api/operation": self.post_api_operation, + } + def init_poll_api_map(self) -> dict: # API map for local to cloud call conversion return { @@ -195,6 +201,28 @@ def poll(self, api: str, force: bool = False, # or pass a custom error response: return {"ERROR": f"Unknown API: {api}"} + def post(self, api: str, payload: Optional[dict], din: Optional[str], + recursive: bool = False, raw: bool = False) -> Optional[Union[dict, list, str, bytes]]: + """ + Map Powerwall API to Tesla Cloud Data + """ + if self.tesla is None: + raise PyPowerwallCloudTeslaNotConnected + # API Map - Determine what data we need based on Powerwall APIs + log.debug(f" -- cloud: Request for {api}") + + func = self.post_api_map.get(api) + if func: + kwargs = { + 'payload': payload, + 'din': din + } + return func(**kwargs) + else: + # raise PyPowerwallCloudNotImplemented(api) + # or pass a custom error response: + return {"ERROR": f"Unknown API: {api}"} + def getsites(self): """ Get list of Tesla Energy sites @@ -924,6 +952,54 @@ def close_session(self): def vitals(self) -> Optional[dict]: return self.poll('/vitals') + def post_api_operation(self, **kwargs): + payload = kwargs.get('payload', {}) + din = kwargs.get('din') + + if not payload.get('backup_reserve_percent') and not payload.get('real_mode'): + raise PyPowerwallCloudInvalidPayload("/api/operation payload missing required parameters. Either " + "'backup_reserve_percent or 'real_mode', or both must present.") + + if not din: + log.warning("No valid DIN provided, will adjust the first battery on site.") + + batteries = self.tesla.battery_list() + log.debug(f"Got batteries: {batteries}") + for battery in batteries: + if din and battery.get('gateway_id') != din: + continue + try: + op_level = battery.set_backup_reserve_percent(payload['backup_reserve_percent']) + op_mode = battery.set_operation(payload['real_mode']) + log.debug(f"Op Level: {op_level}") + log.debug(f"Op Mode: {op_mode}") + return { + 'set_backup_reserve_percent': { + 'backup_reserve_percent': payload['backup_reserve_percent'], + 'din': din, + 'result': op_level + }, + 'set_operation': { + 'real_mode': payload['real_mode'], + 'din': din, + 'result': op_mode + } + } + except Exception as exc: + return {'error': f"{exc}"} + return { + 'set_backup_reserve_percent': { + 'backup_reserve_percent': payload['backup_reserve_percent'], + 'din': din, + 'result': 'BatteryNotFound' + }, + 'set_operation': { + 'real_mode': payload['real_mode'], + 'din': din, + 'result': 'BatteryNotFound' + } + } + if __name__ == "__main__": # Test code diff --git a/pypowerwall/exceptions.py b/pypowerwall/exceptions.py new file mode 100644 index 0000000..f934539 --- /dev/null +++ b/pypowerwall/exceptions.py @@ -0,0 +1,6 @@ +class PyPowerwallInvalidConfigurationParameter(Exception): + pass + + +class InvalidBatteryReserveLevelException(Exception): + pass diff --git a/pypowerwall/local/pypowerwall_local.py b/pypowerwall/local/pypowerwall_local.py index 261b7df..b2d3e20 100644 --- a/pypowerwall/local/pypowerwall_local.py +++ b/pypowerwall/local/pypowerwall_local.py @@ -180,7 +180,7 @@ def poll(self, api: str, force: bool = False, # noinspection PyUnusedLocal payload = r.raw.data self._get_session() - return self.poll(api) + return self.poll(api, raw=raw, recursive=True) else: log.error('Unable to establish session with Powerwall at %s - check password' % url) return None @@ -207,6 +207,65 @@ def poll(self, api: str, force: bool = False, # should be already a dict in cache, so just return it return payload + def post(self, api: str, payload: Optional[dict], din: Optional[str], + recursive: bool = False, raw: bool = False) -> Optional[Union[dict, list, str, bytes]]: + + # We probably should not cache responses here + # Also, we may have to use different HTTP Methods such as POST, PUT, PATCH based on Powerwall API requirements + # For now we assume it's taking POST calls + + url = "https://%s%s" % (self.host, api) + try: + if self.authmode == "token": + r = self.session.post(url, headers=self.auth, json=payload, verify=False, timeout=self.timeout, + stream=raw) + else: + r = self.session.post(url, cookies=self.auth, json=payload, verify=False, timeout=self.timeout, + stream=raw) + except requests.exceptions.Timeout: + log.debug('ERROR Timeout waiting for Powerwall API %s' % url) + return None + except requests.exceptions.ConnectionError: + log.debug('ERROR Unable to connect to Powerwall at %s' % url) + return None + except Exception as exc: + log.debug('ERROR Unknown error connecting to Powerwall at %s: %s' % (url, exc)) + return None + if r.status_code == 404: + log.debug('404 Powerwall API not found at %s' % url) + return None + if 400 <= r.status_code < 500: + # Session Expired - Try to get a new one unless we already tried + log.debug('Session Expired - Trying to get a new one') + if not recursive: + if raw: + # Drain the stream before retrying + # noinspection PyUnusedLocal + response = r.raw.data + self._get_session() + return self.post(api=api, payload=payload, din=din, raw=raw, recursive=True) + else: + log.error('Unable to establish session with Powerwall at %s - check password' % url) + return None + if raw: + response = r.raw.data + else: + response = r.text + if not response: + log.debug(f"Empty response from Powerwall at {url}") + return None + elif 'application/json' in r.headers.get('Content-Type'): + try: + response = json.loads(response) + except Exception as exc: + log.error(f"Unable to parse response '{response}' as JSON, even though it was supposed to " + f"be a json: {exc}") + return None + else: + log.debug(f"Non-json response from Powerwall at {url}: '{response}', serving as is.") + + return response + def version(self, int_value=False): """ Firmware Version """ if not int_value: diff --git a/pypowerwall/pypowerwall_base.py b/pypowerwall/pypowerwall_base.py index 8e263fa..6870eab 100644 --- a/pypowerwall/pypowerwall_base.py +++ b/pypowerwall/pypowerwall_base.py @@ -40,6 +40,11 @@ def poll(self, api: str, force: bool = False, recursive: bool = False, raw: bool = False) -> Optional[Union[dict, list, str, bytes]]: raise NotImplementedError + @abc.abstractmethod + def post(self, api: str, payload: Optional[dict], din: Optional[str], + recursive: bool = False, raw: bool = False) -> Optional[Union[dict, list, str, bytes]]: + raise NotImplementedError + @abc.abstractmethod def vitals(self) -> Optional[dict]: raise NotImplementedError From 8d100faf4bfef47b386b954faf329c86ee61f8f1 Mon Sep 17 00:00:00 2001 From: Igor Cherkaev Date: Tue, 26 Mar 2024 23:51:37 -0500 Subject: [PATCH 02/25] ## v0.8.1 - Set battery reserve, operation mode * Added `set_mode()` function. * Added `set_reserve()` function. * Handle 401/403 errors from Powerwall separately in local mode. * Handle 50x errors from Powerwall in local mode. --- README.md | 2 ++ RELEASE.md | 4 ++++ api_test.py | 13 +++++----- pypowerwall/__init__.py | 26 ++++++++++++++++++++ pypowerwall/local/pypowerwall_local.py | 33 ++++++++++++++++++++++---- 5 files changed, 68 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 5631df2..0dded47 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,8 @@ and call function to poll data. Here is an example: is_connected() # Returns True if able to connect to Powerwall get_reserve(scale) # Get Battery Reserve Percentage get_mode() # Get Current Battery Operation Mode + set_reserve(level) # Set Battery Reserve Percentage + set_mode(mode) # Set Current Battery Operation Mode get_time_remaining() # Get the backup time remaining on the battery set_battery_op_reserve(level, mode, json) # Set Battery Reserve Percentage and/or Operation Mode diff --git a/RELEASE.md b/RELEASE.md index bba80f9..e8b5b9a 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -3,8 +3,12 @@ ## v0.8.1 - Set battery reserve, operation mode * Added `get_mode()` function. +* Added `set_mode()` function. +* Added `set_reserve()` function. * Added `set_battery_op_reserve()` function to set battery operation mode and/or reserve level. Likely won't work in the local mode. * Added basic validation for main class `__init__()` parameters (a.k.a. user input). +* Handle 401/403 errors from Powerwall separately in local mode. +* Handle 50x errors from Powerwall in local mode. ## v0.8.0 - Refactoring diff --git a/api_test.py b/api_test.py index 219749d..4116a20 100644 --- a/api_test.py +++ b/api_test.py @@ -1,6 +1,5 @@ # Test Functions of the Powerwall API import os -import time import pypowerwall @@ -24,7 +23,7 @@ def test_battery_mode_change(pw): else: new_mode = 'self_consumption' - resp = pw.set_battery_op_reserve(mode=new_mode) + resp = pw.set_mode(mode=new_mode) if resp and resp.get('set_operation', {}).get('result') == 'Updated': # if we got a valid response from API, let's assume it worked :) installed_mode = resp.get('set_operation', {}).get('real_mode') @@ -34,7 +33,7 @@ def test_battery_mode_change(pw): if installed_mode != new_mode: print(f"Set battery operation mode to {new_mode} failed.") # revert to original value just in case - pw.set_battery_op_reserve(mode=original_mode) + pw.set_mode(mode=original_mode) def test_battery_reserve_change(pw): @@ -44,7 +43,7 @@ def test_battery_reserve_change(pw): else: new_reserve_level = 50 - resp = pw.set_battery_op_reserve(level=new_reserve_level) + resp = pw.set_reserve(level=new_reserve_level) if resp and resp.get('set_backup_reserve_percent', {}).get('result') == 'Updated': # if we got a valid response from API, let's assume it worked :) installed_level = resp.get('set_backup_reserve_percent', {}).get('backup_reserve_percent') @@ -54,13 +53,15 @@ def test_battery_reserve_change(pw): if installed_level != new_reserve_level: print(f"Set battery reserve level to {new_reserve_level}% failed.") # revert to original value just in case - pw.set_battery_op_reserve(level=original_reserve_level) + pw.set_reserve(level=original_reserve_level) def test_post_functions(pw): # test battery reserve and mode change + print("Testing set_battery_op_reserve()...") test_battery_mode_change(pw) test_battery_reserve_change(pw) + print("Post functions test complete.") def run(include_post_funcs=False): @@ -159,4 +160,4 @@ def run(include_post_funcs=False): if __name__ == "__main__": - run(include_post_funcs=False) + run(include_post_funcs=True) diff --git a/pypowerwall/__init__.py b/pypowerwall/__init__.py index d13e9da..04022fb 100644 --- a/pypowerwall/__init__.py +++ b/pypowerwall/__init__.py @@ -62,6 +62,8 @@ is_connected() # Returns True if able to connect and login to Powerwall get_reserve(scale) # Get Battery Reserve Percentage get_mode() # Get Current Battery Operation Mode + set_reserve(level) # Set Battery Reserve Percentage + set_mode(mode) # Set Current Battery Operation Mode get_time_remaining() # Get the backup time remaining on the battery set_battery_op_reserve(level, mode, json) # Set Battery Reserve Percentage and/or Operation Mode @@ -520,6 +522,30 @@ def get_mode(self, force=False) -> Optional[float]: return data['real_mode'] return None + def set_reserve(self, level: float) -> Optional[dict]: + """ + Set battery reserve level. + + Args: + level: Set battery reserve level in percents (range of 5-100 is accepted) + + Returns: + Dictionary with operation results. + """ + return self.set_battery_op_reserve(level=level) + + def set_mode(self, mode: str) -> Optional[dict]: + """ + Set battery operation mode. + + Args: + mode: Set battery operation mode (self_consumption, backup, autonomous, etc.) + + Returns: + Dictionary with operation results. + """ + return self.set_battery_op_reserve(mode=mode) + def set_battery_op_reserve(self, level: Optional[float] = None, mode: Optional[str] = None, jsonformat: bool = False) -> Optional[Union[dict, str]]: """ diff --git a/pypowerwall/local/pypowerwall_local.py b/pypowerwall/local/pypowerwall_local.py index b2d3e20..050e3de 100644 --- a/pypowerwall/local/pypowerwall_local.py +++ b/pypowerwall/local/pypowerwall_local.py @@ -165,13 +165,13 @@ def poll(self, api: str, force: bool = False, self.pwcachetime[api] = time.perf_counter() + 600 self.pwcache[api] = None return None - if r.status_code == 429: + elif r.status_code == 429: # Rate limited - Switch to cooldown mode for 5 minutes self.pwcooldown = time.perf_counter() + 300 log.error('429 Rate limited by Powerwall API at %s - Activating 5 minute cooldown' % url) - # Serve up cached data if it exists + # Serve up cached data if it exists (@emptywee: this doesn't look like we serve up cached data here?) return None - if 400 <= r.status_code < 500: + elif r.status_code == 401: # Session Expired - Try to get a new one unless we already tried log.debug('Session Expired - Trying to get a new one') if not recursive: @@ -184,6 +184,20 @@ def poll(self, api: str, force: bool = False, else: log.error('Unable to establish session with Powerwall at %s - check password' % url) return None + elif r.status_code == 403: + # Unauthorized + log.error('403 Unauthorized by Powerwall API at %s - Endpoint disabled in this firmware or ' + 'user lacks permission' % url) + self.pwcachetime[api] = time.perf_counter() + 600 + self.pwcache[api] = None + return None + elif 400 <= r.status_code < 500: + log.error('Unhandled HTTP response code %s at %s' % (r.status_code, url)) + return None + elif r.status_code >= 500: + log.error('Server-side problem at Powerwall API (status code %s) at %s' % (r.status_code, url)) + return None + if raw: payload = r.raw.data else: @@ -234,7 +248,7 @@ def post(self, api: str, payload: Optional[dict], din: Optional[str], if r.status_code == 404: log.debug('404 Powerwall API not found at %s' % url) return None - if 400 <= r.status_code < 500: + elif r.status_code == 401: # Session Expired - Try to get a new one unless we already tried log.debug('Session Expired - Trying to get a new one') if not recursive: @@ -247,6 +261,17 @@ def post(self, api: str, payload: Optional[dict], din: Optional[str], else: log.error('Unable to establish session with Powerwall at %s - check password' % url) return None + elif r.status_code == 403: + # Unauthorized + log.error('403 Unauthorized by Powerwall API at %s - Endpoint disabled in this firmware or ' + 'user lacks permission' % url) + return None + elif 400 <= r.status_code < 500: + log.error('Unhandled HTTP response code %s at %s' % (r.status_code, url)) + return None + elif r.status_code >= 500: + log.error('Server-side problem at Powerwall API (status code %s) at %s' % (r.status_code, url)) + return None if raw: response = r.raw.data else: From 49923fca21e04ddf2ccc8e098bfb7c90bd72f5e4 Mon Sep 17 00:00:00 2001 From: Igor Cherkaev Date: Tue, 26 Mar 2024 23:53:51 -0500 Subject: [PATCH 03/25] rename `set_battery_op_reserve()` to `set_operation()` --- README.md | 2 +- RELEASE.md | 2 +- api_test.py | 2 +- pypowerwall/__init__.py | 10 +++++----- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 0dded47..c9dd094 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,7 @@ and call function to poll data. Here is an example: set_mode(mode) # Set Current Battery Operation Mode get_time_remaining() # Get the backup time remaining on the battery - set_battery_op_reserve(level, mode, json) # Set Battery Reserve Percentage and/or Operation Mode + set_operation(level, mode, json) # Set Battery Reserve Percentage and/or Operation Mode ``` diff --git a/RELEASE.md b/RELEASE.md index e8b5b9a..f124735 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -5,7 +5,7 @@ * Added `get_mode()` function. * Added `set_mode()` function. * Added `set_reserve()` function. -* Added `set_battery_op_reserve()` function to set battery operation mode and/or reserve level. Likely won't work in the local mode. +* Added `set_operation()` function to set battery operation mode and/or reserve level. Likely won't work in the local mode. * Added basic validation for main class `__init__()` parameters (a.k.a. user input). * Handle 401/403 errors from Powerwall separately in local mode. * Handle 50x errors from Powerwall in local mode. diff --git a/api_test.py b/api_test.py index 4116a20..d7b4d72 100644 --- a/api_test.py +++ b/api_test.py @@ -58,7 +58,7 @@ def test_battery_reserve_change(pw): def test_post_functions(pw): # test battery reserve and mode change - print("Testing set_battery_op_reserve()...") + print("Testing set_operation()...") test_battery_mode_change(pw) test_battery_reserve_change(pw) print("Post functions test complete.") diff --git a/pypowerwall/__init__.py b/pypowerwall/__init__.py index 04022fb..29cc207 100644 --- a/pypowerwall/__init__.py +++ b/pypowerwall/__init__.py @@ -66,7 +66,7 @@ set_mode(mode) # Set Current Battery Operation Mode get_time_remaining() # Get the backup time remaining on the battery - set_battery_op_reserve(level, mode, json) # Set Battery Reserve Percentage and/or Operation Mode + set_operation(level, mode, json) # Set Battery Reserve Percentage and/or Operation Mode Requirements This module requires the following modules: requests, protobuf, teslapy @@ -532,7 +532,7 @@ def set_reserve(self, level: float) -> Optional[dict]: Returns: Dictionary with operation results. """ - return self.set_battery_op_reserve(level=level) + return self.set_operation(level=level) def set_mode(self, mode: str) -> Optional[dict]: """ @@ -544,10 +544,10 @@ def set_mode(self, mode: str) -> Optional[dict]: Returns: Dictionary with operation results. """ - return self.set_battery_op_reserve(mode=mode) + return self.set_operation(mode=mode) - def set_battery_op_reserve(self, level: Optional[float] = None, mode: Optional[str] = None, - jsonformat: bool = False) -> Optional[Union[dict, str]]: + def set_operation(self, level: Optional[float] = None, mode: Optional[str] = None, + jsonformat: bool = False) -> Optional[Union[dict, str]]: """ Set battery operation mode and reserve level. From 055de19d8df58438903a5ccb40b2aff3882f7b45 Mon Sep 17 00:00:00 2001 From: Igor Cherkaev Date: Wed, 27 Mar 2024 23:10:15 -0500 Subject: [PATCH 04/25] pwsim respond with 401 to simulate an expired token --- pwsimulator/stub.py | 95 ++++++++++++++------------ pypowerwall/local/pypowerwall_local.py | 1 - 2 files changed, 50 insertions(+), 46 deletions(-) diff --git a/pwsimulator/stub.py b/pwsimulator/stub.py index 0095b01..7a7ddfb 100644 --- a/pwsimulator/stub.py +++ b/pwsimulator/stub.py @@ -49,37 +49,38 @@ '/api/meters/aggregates': '{"site":{"last_communication_time":"2021-10-17T07:23:34.290637169-07:00","instant_power":%d,"instant_reactive_power":-439,"instant_apparent_power":1414.830731925201,"frequency":0,"energy_exported":687.6503234502925,"energy_imported":887602.0847810425,"instant_average_voltage":210.20168600655896,"instant_average_current":12.47,"i_a_current":0,"i_b_current":0,"i_c_current":0,"last_phase_voltage_communication_time":"0001-01-01T00:00:00Z","last_phase_power_communication_time":"0001-01-01T00:00:00Z","timeout":1500000000,"num_meters_aggregated":1,"instant_total_current":12.47},"battery":{"last_communication_time":"2021-10-17T07:23:34.289652105-07:00","instant_power":%d,"instant_reactive_power":330,"instant_apparent_power":335.4101966249685,"frequency":60.019999999999996,"energy_exported":136540,"energy_imported":161080,"instant_average_voltage":242.95,"instant_average_current":0.6000000000000001,"i_a_current":0,"i_b_current":0,"i_c_current":0,"last_phase_voltage_communication_time":"0001-01-01T00:00:00Z","last_phase_power_communication_time":"0001-01-01T00:00:00Z","timeout":1500000000,"num_meters_aggregated":2,"instant_total_current":0.6000000000000001},"load":{"last_communication_time":"2021-10-17T07:23:34.289652105-07:00","instant_power":%d,"instant_reactive_power":-131,"instant_apparent_power":1546.0599115170148,"frequency":0,"energy_exported":0,"energy_imported":1120094.4344575922,"instant_average_voltage":210.20168600655896,"instant_average_current":7.328675755492901,"i_a_current":0,"i_b_current":0,"i_c_current":0,"last_phase_voltage_communication_time":"0001-01-01T00:00:00Z","last_phase_power_communication_time":"0001-01-01T00:00:00Z","timeout":1500000000,"instant_total_current":7.328675755492901},"solar":{"last_communication_time":"2021-10-17T07:23:34.290245943-07:00","instant_power":%d,"instant_reactive_power":-20,"instant_apparent_power":240.8318915758459,"frequency":60.012,"energy_exported":257720,"energy_imported":0,"instant_average_voltage":242.4,"instant_average_current":0.9488448844884488,"i_a_current":0,"i_b_current":0,"i_c_current":0,"last_phase_voltage_communication_time":"0001-01-01T00:00:00Z","last_phase_power_communication_time":"0001-01-01T00:00:00Z","timeout":1000000000,"num_meters_aggregated":1,"instant_total_current":0.9488448844884488}}' % (GRID, POWERWALL, HOME, SOLAR), '/api/system_status/soe': '{"percentage":23.975388097174584}', '/api/system_status/grid_status': '{"grid_status":"SystemGridConnected","grid_services_active":false}', - '/api/powerwalls': '{"powerwalls":[]}', - '/api/auth/toggle/supported': '{"toggle_auth_supported":true}', + '/api/powerwalls': '{"powerwalls":[]}', + '/api/auth/toggle/supported': '{"toggle_auth_supported":true}', '/api/solar_powerwall': '{"pvac_status":{"state":"PVAC_Active","disabled":false,"disabled_reasons":[],"grid_state":"Grid_Compliant","inv_state":"INV_Grid_Connected","v_out":241.60000000000002,"f_out":59.992000000000004,"p_out":0,"q_out":-10,"i_out":1.12,"string_vitals":[{"string_id":1,"connected":true,"measured_voltage":10.3,"current":0,"measured_power":0},{"string_id":2,"connected":false,"measured_voltage":33.2,"current":0,"measured_power":0},{"string_id":3,"connected":true,"measured_voltage":11,"current":0,"measured_power":0},{"string_id":4,"connected":true,"measured_voltage":10.3,"current":0,"measured_power":0}]},"pvs_status":{"state":"PVS_GridSupporting","disabled":false,"enable_output":true,"v_ll":242.4,"self_test_state":"PVS_SelfTestOff"},"pv_power_limit":230.39999999999998,"power_status_setpoint":"on","pvac_alerts":{"LastRxTime":"2024-03-18T20:40:15.175038-07:00","ReceivedMuxBitmask":1,"PVAC_alertMatrixIndex":0,"PVAC_a001_inv_L1_HW_overcurrent":false,"PVAC_a002_inv_L2_HW_overcurrent":false,"PVAC_a003_inv_HVBus_HW_overvoltage":false,"PVAC_a004_pv_HW_CMPSS_OC_STGA":false,"PVAC_a005_pv_HW_CMPSS_OC_STGB":false,"PVAC_a006_pv_HW_CMPSS_OC_STGC":false,"PVAC_a007_pv_HW_CMPSS_OC_STGD":false,"PVAC_a008_inv_HVBus_undervoltage":false,"PVAC_a009_SwAppBoot":false,"PVAC_a010_inv_AC_overvoltage":false,"PVAC_a011_inv_AC_undervoltage":false,"PVAC_a012_inv_AC_overfrequency":false,"PVAC_a013_inv_AC_underfrequency":false,"PVAC_a014_PVS_disabled_relay":false,"PVAC_a015_pv_HW_Allegro_OC_STGA":false,"PVAC_a016_pv_HW_Allegro_OC_STGB":false,"PVAC_a017_pv_HW_Allegro_OC_STGC":false,"PVAC_a018_pv_HW_Allegro_OC_STGD":false,"PVAC_a019_ambient_overtemperature":false,"PVAC_a020_dsp_overtemperature":false,"PVAC_a021_dcac_heatsink_overtemperature":false,"PVAC_a022_mppt_heatsink_overtemperature":false,"PVAC_a023_unused":false,"PVAC_a024_PVACrx_Command_mia":false,"PVAC_a025_PVS_Status_mia":false,"PVAC_a026_inv_AC_peak_overvoltage":false,"PVAC_a027_inv_K1_relay_welded":false,"PVAC_a028_inv_K2_relay_welded":false,"PVAC_a029_pump_faulted":false,"PVAC_a030_fan_faulted":false,"PVAC_a031_VFCheck_OV":false,"PVAC_a032_VFCheck_UV":false,"PVAC_a033_VFCheck_OF":false,"PVAC_a034_VFCheck_UF":false,"PVAC_a035_VFCheck_RoCoF":false,"PVAC_a036_inv_lost_iL_control":false,"PVAC_a037_PVS_processor_nERROR":false,"PVAC_a038_inv_failed_xcap_precharge":false,"PVAC_a039_inv_HVBus_SW_overvoltage":false,"PVAC_a040_pump_correction_saturated":false,"PVAC_a041_excess_PV_clamp_triggered":false,"PVAC_a042_mppt_curve_scan_completed":false,"PVAC_a043_fan_speed_mismatch_detected":false,"PVAC_a044_fan_deadband_toggled":false},"pvs_alerts":{"LastRxTime":"2024-03-18T20:40:15.065876-07:00","ReceivedMuxBitmask":0,"PVS_a001_WatchdogReset":false,"PVS_a002_SW_App_Boot":false,"PVS_a003_V12vOutOfBounds":false,"PVS_a004_V1v5OutOfBounds":false,"PVS_a005_VAfdRefOutOfBounds":false,"PVS_a006_GfOvercurrent300":false,"PVS_a007_UNUSED_7":false,"PVS_a008_UNUSED_8":false,"PVS_a009_GfOvercurrent030":false,"PVS_a010_PvIsolationTotal":false,"PVS_a011_PvIsolationStringA":false,"PVS_a012_PvIsolationStringB":false,"PVS_a013_PvIsolationStringC":false,"PVS_a014_PvIsolationStringD":false,"PVS_a015_SelfTestGroundFault":false,"PVS_a016_ESMFault":false,"PVS_a017_MciStringA":false,"PVS_a018_MciStringB":true,"PVS_a019_MciStringC":false,"PVS_a020_MciStringD":false,"PVS_a021_RapidShutdown":false,"PVS_a022_Mci1SignalLevel":false,"PVS_a023_Mci2SignalLevel":false,"PVS_a024_Mci3SignalLevel":false,"PVS_a025_Mci4SignalLevel":false,"PVS_a026_Mci1PvVoltage":false,"PVS_a027_Mci2PvVoltage":false,"PVS_a028_systemInitFailed":false,"PVS_a029_PvArcFault":false,"PVS_a030_VDcOv":false,"PVS_a031_Mci3PvVoltage":false,"PVS_a032_Mci4PvVoltage":false,"PVS_a033_dataException":false,"PVS_a034_PeImpedance":false,"PVS_a035_PvArcDetected":false,"PVS_a036_PvArcLockout":false,"PVS_a037_PvArcFaultData1":false,"PVS_a038_PvArcFault_SelfTest":false,"PVS_a039_SelfTestRelayFault":false,"PVS_a040_LEDIrrationalFault":false,"PVS_a041_MciPowerSwitch":false,"PVS_a042_MciPowerFault":false,"PVS_a043_InactiveUnsafePvStrings":false,"PVS_a044_FaultStatePvStringSafety":false,"PVS_a045_RelayCoilIrrationalFault":false,"PVS_a046_RelayCoilIrrationalLockout":false,"PVS_a047_AcSensorIrrationalFault":false,"PVS_a048_DcSensorIrrationalFault":false,"PVS_a049_arcSignalMibspiHealth":false,"PVS_a050_RelayCoilIrrationalWarning":false,"PVS_a051_DcBusShortCircuitDetected":false,"PVS_a052_PvArcFault_PreSelfTest":false,"PVS_a053_PvArcFaultData2":false,"PVS_a054_PvArcFaultData3":false,"PVS_a055_PvArcFaultData4":false,"PVS_a056_PvIsolation24HrLockout":false,"PVS_a057_DisabledDuringSelftest":false,"PVS_a058_MciOpenOnFault":false,"PVS_a059_MciOpen":true,"PVS_a060_MciClose":false,"PVS_a061_SelfTestRelayFaultLockout":false,"PVS_a062_arcSoftLockout":false,"PVS_a063_sbsComplete_info":false}}', } + # Handlers -class handler(BaseHTTPRequestHandler): +class Handler(BaseHTTPRequestHandler): # POST Handler def do_POST(self): message = "ERROR!" - valid = False + session_valid = False # Login if self.path == '/api/login/Basic': - valid = True + session_valid = True self.send_response(200) self.send_header('Content-type','application/json') self.send_header('set-cookie', 'AuthCookie=1234567890qwertyuiopasdfghjklZXcvbnm1234567890Qwertyuiopasdfghjklzxcvbnm1234567890qwer==; Path=/') self.send_header('set-cookie', 'UserRecord=1234567890qwertyuiopasdfghjklZXcvbnm1234567890Qwertyuiopasdfghjklzxcvbnm1234567890qwer1234567890qwertyuiopasdfghjklZXcvbnm1234567890Qwertyuiopasdfghjklzxcvbnm1234567890qwer1234567890qwertyuiopasdfghjklZXcvbnm1234567890Qwertyuiopasdfghjklzxcvbnm1234567890qwer1234567890qwertyuiopasdfghjklZXcvbnm1234567890Qwertyuiopasdfghjklzxcvbnm1234567890qwer123456==; Path=/') self.end_headers() - message = '{"email":"test@example.com","firstname":"Tesla","lastname":"Energy","roles":["Home_Owner"],"token":"1234567890qwertyuiopasdfghjklZXcvbnm1234567890Qwertyuiopasdfghjklzxcvbnm1234567890qwer==","provider":"Basic","loginTime":"2021-10-17T00:39:09.852064316-07:00"}' + message = '{"email":"test@example.com","firstname":"Tesla","lastname":"Energy","roles":["Home_Owner"],"token":"1234567890qwertyuiopasdfghjklZXcvbnm1234567890Qwertyuiopasdfghjklzxcvbnm1234567890qwer==","provider":"Basic","loginTime":"2021-10-17T00:39:09.852064316-07:00"}' # TODO: Add check for right login credentials or send 401 # self.send_response(401) # self.send_header('Content-type','application/json') # message = '{"code":401,"error":"bad credentials","message":"Login Error"}' # - # Error - if (not valid): - self.send_response(403) - self.send_header('Content-type','application/json') + # Token expired + if not session_valid: + self.send_response(401) + self.send_header('Content-type', 'application/json') self.end_headers() - message = '{"code":403,"error":"Unable to GET to resource","message":"User does not have adequate access rights"}' + message = '{"code":401,"message":"Token Expired"}' # Send Response self.wfile.write(bytes(message, "utf8")) @@ -87,87 +88,91 @@ def do_POST(self): # GET Handler def do_GET(self): message = "ERROR!" - valid = False # Handlers # # Status - SOE if self.path == '/api/status': - valid = False - if('cookie' in self.headers and '1234567890qwertyuiopasdfghjklZXcvbnm' in self.headers['cookie']): + session_valid = False + if 'cookie' in self.headers and '1234567890qwertyuiopasdfghjklZXcvbnm' in self.headers['cookie']: # Valid Login self.send_response(200) - self.send_header('Content-type','application/json') + self.send_header('Content-type', 'application/json') self.end_headers() - payload = {'din': '1232100-00-E--TG123456789ABC', 'start_time': '2024-03-11 09:12:41 +0800', 'up_time_seconds': '127h34m16.275122187s', 'is_new': False, 'version': '23.44.0 9064fc6a', 'git_hash': '4064fc6a5b32425509f91f19556f2431cb7f6872', 'commission_count': 0, 'device_type': 'teg', 'teg_type': 'unknown', 'sync_type': 'v2.1', 'cellular_disabled': False, 'can_reboot': True} + payload = {'din': '1232100-00-E--TG123456789ABC', 'start_time': '2024-03-11 09:12:41 +0800', + 'up_time_seconds': '127h34m16.275122187s', 'is_new': False, 'version': '23.44.0 9064fc6a', + 'git_hash': '4064fc6a5b32425509f91f19556f2431cb7f6872', 'commission_count': 0, + 'device_type': 'teg', 'teg_type': 'unknown', 'sync_type': 'v2.1', 'cellular_disabled': False, + 'can_reboot': True} # convert payload to json message = json.dumps(payload) - valid = True - + session_valid = True + # Static API Values elif self.path in api: - valid = False - if('cookie' in self.headers and '1234567890qwertyuiopasdfghjklZXcvbnm' in self.headers['cookie']): + session_valid = False + if 'cookie' in self.headers and '1234567890qwertyuiopasdfghjklZXcvbnm' in self.headers['cookie']: # Valid Login self.send_response(200) - self.send_header('Content-type','application/json') + self.send_header('Content-type', 'application/json') self.end_headers() message = api[self.path] - valid = True - + session_valid = True + # Vitals - Firmware 23.44.0+ does not support this API elif self.path == '/api/devices/vitals': - valid = False - if('cookie' in self.headers and '1234567890qwertyuiopasdfghjklZXcvbnm' in self.headers['cookie']): + session_valid = False + if 'cookie' in self.headers and '1234567890qwertyuiopasdfghjklZXcvbnm' in self.headers['cookie']: # Valid Login if not VITALS: print("Firmware does not support vitals API") self.send_response(404) - self.send_header('Content-type','application/json') + self.send_header('Content-type', 'application/json') self.end_headers() message = '{"code":404,"error":"File Not Found","message":"Firmware does not support vitals API"}' - valid = True + session_valid = True else: # Send protobuf example payload print("Sending vitals.protobuf.bin") self.send_response(200) - self.send_header('Content-type','application/octet-stream') + self.send_header('Content-type', 'application/octet-stream') self.end_headers() message = b'\n\xf9\x01\n~\n|\n&\n$STSTSM--1232100-00-E--T0000000000000\x12\x0e\n\x0c1232100-00-E\x1a\x10\n\x0eT0000000000000"\x07\n\x05TESLA:\x18\n\x162023-11-30-g6e07d12eeaJ\x06\x08\xc8\xe4\xed\xae\x06Z\x05\n\x03\x08\xcf\x01\x12\x1a\n\x0fSTSTSM-Location*\x07Gateway\x1a\x0eGridCodesWrite\x1a\x0eGridCodesWrite\x1a\x15SystemConnectedToGrid\x1a\x11FWUpdateSucceeded\x1a\x11PodCommissionTime\n\xe7\x01\n\x9e\x01\n\x9b\x01\n%\n#TETHC--2012170-25-E--T0000000000000\x12\x0e\n\x0c2012170-25-E\x1a\x10\n\x0eT0000000000000"\x07\n\x05TESLA2&\n$STSTSM--1232100-00-E--T0000000000000:\x10\n\x0e4064fc6a5b3242J\x06\x08\xc8\xe4\xed\xae\x06Z\x05\n\x03\x08\xe0\x01\x12(\n\tTHC_State*\x1bTHC_STATE_AUTONOMOUSCONTROL\x12\x1a\n\x0fTHC_AmbientTemp!833333/@\n\xb4\x04\n\x97\x01\n\x94\x01\n"\n TEPOD--1081100-13-V--T0000000000\x12\x0e\n\x0c1081100-13-V\x1a\r\n\x0bT0000000000"\x07\n\x05TESLA2%\n#TETHC--2012170-25-E--T0000000000000:\x10\n\x0e4064fc6a5b3242J\x06\x08\xc7\xe4\xed\xae\x06Z\x05\n\x03\x08\xe2\x01\x12\'\n\x1cPOD_nom_energy_to_be_charged!\x00\x00\x00\x00\x00v\xc1@\x12#\n\x18POD_nom_energy_remaining!\x00\x00\x00\x00\x00k\xb2@\x12#\n\x18POD_nom_full_pack_energy!\x00\x00\x00\x00\x00\xae\xc9@\x12%\n\x1aPOD_available_charge_power!\x00\x00\x00\x00\x00\xc4\xb8@\x12%\n\x1aPOD_available_dischg_power!\x00\x00\x00\x00\x00p\xc7@\x12\x17\n\tPOD_state*\nPOD_ACTIVE\x12\x13\n\x0fPOD_enable_line0\x01\x12\x16\n\x12POD_ChargeComplete0\x00\x12\x19\n\x15POD_DischargeComplete0\x00\x12\x1b\n\x17POD_PersistentlyFaulted0\x00\x12\x1a\n\x16POD_PermanentlyFaulted0\x00\x12\x15\n\x11POD_ChargeRequest0\x00\x12\x15\n\x11POD_ActiveHeating0\x00\x12\x0f\n\x0bPOD_CCVhold0\x00\n\xe9\x04\n\x98\x01\n\x95\x01\n#\n!TEPINV--1081100-13-V--T0000000000\x12\x0e\n\x0c1081100-13-V\x1a\r\n\x0bT0000000000"\x07\n\x05TESLA2%\n#TETHC--2012170-25-E--T0000000000000:\x10\n\x0e4064fc6a5b3242J\x06\x08\xc8\xe4\xed\xae\x06Z\x05\n\x03\x08\xfd\x01\x12 \n\x15PINV_EnergyDischarged!\x00\x00\x00\xc0\r\xceYA\x12\x1d\n\x12PINV_EnergyCharged!\x00\x00\x00\x00d\x0b]A\x12\x17\n\x0cPINV_VSplit1!\x00\x00\x00\x00\x00@^@\x12\x17\n\x0cPINV_VSplit2!\x9a\x99\x99\x99\x999^@\x12\x1c\n\x11PINV_PllFrequency!\x1a/\xdd$\x06\x01N@\x12\x12\n\x0ePINV_PllLocked0\x01\x12\x14\n\tPINV_Pout!H\xe1z\x14\xaeG\xe1\xbf\x12\x14\n\tPINV_Qout!{\x14\xaeG\xe1z\x84?\x12\x14\n\tPINV_Vout!43333Cn@\x12\x14\n\tPINV_Fout!\xa6\x9b\xc4 \xb0\x02N@\x12\x1c\n\x18PINV_ReadyForGridForming0\x01\x12 \n\nPINV_State*\x12PINV_GridFollowing\x12 \n\x0ePINV_GridState*\x0eGrid_Compliant\x12\x1b\n\x17PINV_HardwareEnableLine0\x01\x12+\n\x11PINV_PowerLimiter*\x16PWRLIM_POD_Power_Limit\x1a#PINV_a067_overvoltageNeutralChassis\n\xe7\x01\n\x9e\x01\n\x9b\x01\n%\n#TETHC--3012170-05-B--T0000000000000\x12\x0e\n\x0c3012170-05-B\x1a\x10\n\x0eT0000000000000"\x07\n\x05TESLA2&\n$STSTSM--1232100-00-E--T0000000000000:\x10\n\x0e4064fc6a5b3242J\x06\x08\xc8\xe4\xed\xae\x06Z\x05\n\x03\x08\xe0\x01\x12(\n\tTHC_State*\x1bTHC_STATE_AUTONOMOUSCONTROL\x12\x1a\n\x0fTHC_AmbientTemp!hfffff.@\n\xb4\x04\n\x97\x01\n\x94\x01\n"\n TEPOD--1081100-10-U--T0000000000\x12\x0e\n\x0c1081100-10-U\x1a\r\n\x0bT0000000000"\x07\n\x05TESLA2%\n#TETHC--3012170-05-B--T0000000000000:\x10\n\x0e4064fc6a5b3242J\x06\x08\xc8\xe4\xed\xae\x06Z\x05\n\x03\x08\xe2\x01\x12\'\n\x1cPOD_nom_energy_to_be_charged!\x00\x00\x00\x00\x80\xce\xc0@\x12#\n\x18POD_nom_energy_remaining!\x00\x00\x00\x00\x00\x98\xb1@\x12#\n\x18POD_nom_full_pack_energy!\x00\x00\x00\x00\x00\x8e\xc8@\x12%\n\x1aPOD_available_charge_power!\x00\x00\x00\x00\x00X\xbb@\x12%\n\x1aPOD_available_dischg_power!\x00\x00\x00\x00\x00p\xc7@\x12\x17\n\tPOD_state*\nPOD_ACTIVE\x12\x13\n\x0fPOD_enable_line0\x01\x12\x16\n\x12POD_ChargeComplete0\x00\x12\x19\n\x15POD_DischargeComplete0\x00\x12\x1b\n\x17POD_PersistentlyFaulted0\x00\x12\x1a\n\x16POD_PermanentlyFaulted0\x00\x12\x15\n\x11POD_ChargeRequest0\x00\x12\x15\n\x11POD_ActiveHeating0\x00\x12\x0f\n\x0bPOD_CCVhold0\x00\n\xe9\x04\n\x98\x01\n\x95\x01\n#\n!TEPINV--1081100-10-U--T0000000000\x12\x0e\n\x0c1081100-10-U\x1a\r\n\x0bT0000000000"\x07\n\x05TESLA2%\n#TETHC--3012170-05-B--T0000000000000:\x10\n\x0e4064fc6a5b3242J\x06\x08\xc8\xe4\xed\xae\x06Z\x05\n\x03\x08\xfd\x01\x12 \n\x15PINV_EnergyDischarged!\x00\x00\x00\x80\xeb\x12YA\x12\x1d\n\x12PINV_EnergyCharged!\x00\x00\x00\x80\x013\\A\x12\x17\n\x0cPINV_VSplit1!\xcd\xcc\xcc\xcc\xccL^@\x12\x17\n\x0cPINV_VSplit2!\x9a\x99\x99\x99\x999^@\x12\x1c\n\x11PINV_PllFrequency!\xc4 \xb0rh\x01N@\x12\x12\n\x0ePINV_PllLocked0\x01\x12\x14\n\tPINV_Pout!\xa4p=\n\xd7\xa3\xe0\xbf\x12\x14\n\tPINV_Qout!\x00\x00\x00\x00\x00\x00\x00\x00\x12\x14\n\tPINV_Vout!gffffFn@\x12\x14\n\tPINV_Fout!\xa6\x9b\xc4 \xb0\x02N@\x12\x1c\n\x18PINV_ReadyForGridForming0\x01\x12 \n\nPINV_State*\x12PINV_GridFollowing\x12 \n\x0ePINV_GridState*\x0eGrid_Compliant\x12\x1b\n\x17PINV_HardwareEnableLine0\x01\x12+\n\x11PINV_PowerLimiter*\x16PWRLIM_POD_Power_Limit\x1a#PINV_a067_overvoltageNeutralChassis\n\xa0\x08\n\x9c\x01\n\x99\x01\n$\n"PVAC--1538100-00-F--C0000000000000\x12\x0e\n\x0c1538100-00-F\x1a\x10\n\x0eC0000000000000"\x07\n\x05TESLA2%\n#TETHC--2012170-25-E--T0000000000000:\x10\n\x0e4064fc6a5b3242J\x06\x08\xc8\xe4\xed\xae\x06Z\x05\n\x03\x08\xa8\x02\x12\x14\n\tPVAC_Iout!\n\xd7\xa3p=\n\x1f@\x12\x19\n\x0ePVAC_VL1Ground!\x99\x99\x99\x99\x999^@\x12\x19\n\x0ePVAC_VL2Ground!\x99\x99\x99\x99\x999^@\x12!\n\x16PVAC_VHvMinusChassisDC!\x00\x00\x00\x00\x00@j\xc0\x12\x1b\n\x10PVAC_PVCurrent_A!\xd7\xa3p=\n\xd7\xfb?\x12\x1b\n\x10PVAC_PVCurrent_B!\x00\x00\x00\x00\x00\x00\x00\x00\x12\x1b\n\x10PVAC_PVCurrent_C!gfffff\x04@\x12\x1b\n\x10PVAC_PVCurrent_D!\xecQ\xb8\x1e\x85\xeb\x03@\x12#\n\x18PVAC_PVMeasuredVoltage_A!gffff\x0ep@\x12#\n\x18PVAC_PVMeasuredVoltage_B!\xcc\xcc\xcc\xcc\xcc\xcc\x00\xc0\x12#\n\x18PVAC_PVMeasuredVoltage_C!gfffffr@\x12#\n\x18PVAC_PVMeasuredVoltage_D!\xcd\xcc\xcc\xcc\xcclr@\x12!\n\x16PVAC_PVMeasuredPower_A!\x00\x00\x00\x00\x00\x90{@\x12!\n\x16PVAC_PVMeasuredPower_B!\x00\x00\x00\x00\x00\x00\x00\x00\x12!\n\x16PVAC_PVMeasuredPower_C!\x00\x00\x00\x00\x00P\x87@\x12!\n\x16PVAC_PVMeasuredPower_D!\x00\x00\x00\x00\x00\xd8\x86@\x12&\n\x1bPVAC_LifetimeEnergyPV_Total!\x00\x00\x00\xc0\x89izA\x12\x14\n\tPVAC_Vout!433333n@\x12\x14\n\tPVAC_Fout!6^\xbaI\x0c\x02N@\x12\x14\n\tPVAC_Pout!\x00\x00\x00\x00\x00\xa0\x9e@\x12\x14\n\tPVAC_Qout!\x00\x00\x00\x00\x00\x004@\x12\x19\n\nPVAC_State*\x0bPVAC_Active\x12 \n\x0ePVAC_GridState*\x0eGrid_Compliant\x12#\n\rPVAC_InvState*\x12INV_Grid_Connected\x12\x1b\n\x0ePVAC_PvState_A*\tPV_Active\x12\x1b\n\x0ePVAC_PvState_B*\tPV_Active\x12\x1b\n\x0ePVAC_PvState_C*\tPV_Active\x12$\n\x0ePVAC_PvState_D*\x12PV_Active_Parallel\x12\x1d\n\x17PVI-PowerStatusSetpoint*\x02on\n\x9a\x03\n\x9a\x01\n\x97\x01\n#\n!PVS--1538100-00-F--C0000000000000\x12\x0e\n\x0c1538100-00-F\x1a\x10\n\x0eC0000000000000"\x07\n\x05TESLA2$\n"PVAC--1538100-00-F--C0000000000000:\x10\n\x0e44857755923fa5J\x06\x08\xc8\xe4\xed\xae\x06Z\x05\n\x03\x08\xa9\x02\x12\x12\n\x07PVS_vLL!433333n@\x12\x17\n\tPVS_State*\nPVS_Active\x12$\n\x11PVS_SelfTestState*\x0fPVS_SelfTestOff\x12\x14\n\x10PVS_EnableOutput0\x01\x12\x19\n\x15PVS_StringA_Connected0\x01\x12\x19\n\x15PVS_StringB_Connected0\x00\x12\x19\n\x15PVS_StringC_Connected0\x01\x12\x19\n\x15PVS_StringD_Connected0\x01\x1a\x13PVS_a018_MciStringB\x1a\x11PVS_a060_MciClose\n\xc1\x0f\n\x9f\x01\n\x9c\x01\n&\n$TESYNC--1493315-01-F--J0000000000000\x12\x0e\n\x0c1493315-01-F\x1a\x10\n\x0eJ0000000000000"\x07\n\x05TESLA2&\n$STSTSM--1232100-00-E--T0000000000000:\x10\n\x0e4064fc6a5b3242J\x06\x08\xc8\xe4\xed\xae\x06Z\x05\n\x03\x08\x83\x02\x12\x1b\n\x10ISLAND_VL1N_Main!\x00\x00\x00\x00\x00`^@\x12\x1d\n\x12ISLAND_FreqL1_Main!\xc2\xf5(\\\x8f\x02N@\x12\x1b\n\x10ISLAND_VL1N_Load!\x00\x00\x00\x00\x00`^@\x12\x1d\n\x12ISLAND_FreqL1_Load!\xc2\xf5(\\\x8f\x02N@\x12#\n\x18ISLAND_PhaseL1_Main_Load!\x00\x00\x00\x00\x00\x00\x00\x00\x12\x1b\n\x10ISLAND_VL2N_Main!\x00\x00\x00\x00\x00`^@\x12\x1d\n\x12ISLAND_FreqL2_Main!\xc2\xf5(\\\x8f\x02N@\x12\x1b\n\x10ISLAND_VL2N_Load!\x00\x00\x00\x00\x00`^@\x12\x1d\n\x12ISLAND_FreqL2_Load!\xc2\xf5(\\\x8f\x02N@\x12#\n\x18ISLAND_PhaseL2_Main_Load!\x00\x00\x00\x00\x00\x00\x00\x00\x12\x1b\n\x10ISLAND_VL3N_Main!\x00\x00\x00\x00\x00\x00\x00\x00\x12\x1d\n\x12ISLAND_FreqL3_Main!\x00\x00\x00\x00\x00\x00\x00\x00\x12\x1b\n\x10ISLAND_VL3N_Load!\x00\x00\x00\x00\x00\x00\x00\x00\x12\x1d\n\x12ISLAND_FreqL3_Load!\x00\x00\x00\x00\x00\x00\x00\x00\x12#\n\x18ISLAND_PhaseL3_Main_Load!\x00\x00\x00\x00\x00\x90f\xc0\x12 \n\x15ISLAND_L1L2PhaseDelta!\x00\x00\x00\x00\x00\x90f\xc0\x12 \n\x15ISLAND_L1L3PhaseDelta!\x00\x00\x00\x00\x00\x90f\xc0\x12 \n\x15ISLAND_L2L3PhaseDelta!\x00\x00\x00\x00\x00\x90f\xc0\x123\n\x10ISLAND_GridState*\x1fISLAND_GridState_Grid_Compliant\x12\x18\n\x14ISLAND_L1MicrogridOk0\x01\x12\x18\n\x14ISLAND_L2MicrogridOk0\x01\x12\x18\n\x14ISLAND_L3MicrogridOk0\x00\x12"\n\x1eISLAND_ReadyForSynchronization0\x01\x12\x18\n\x14ISLAND_GridConnected0\x01\x12\x1a\n\x16SYNC_ExternallyPowered0\x00\x12\x1a\n\x16SYNC_SiteSwitchEnabled0\x00\x12$\n\x19METER_X_CTA_InstRealPower!\x00\x00\x00\x00\x00\xa0i@\x12$\n\x19METER_X_CTB_InstRealPower!\x00\x00\x00\x00\x00`b\xc0\x12$\n\x19METER_X_CTC_InstRealPower!\x00\x00\x00\x00\x00\x00\x00\x00\x12(\n\x1dMETER_X_CTA_InstReactivePower!\x00\x00\x00\x00\x00\x00M\xc0\x12(\n\x1dMETER_X_CTB_InstReactivePower!\x00\x00\x00\x00\x00\x80Q\xc0\x12(\n\x1dMETER_X_CTC_InstReactivePower!\x00\x00\x00\x00\x00\x00\x00\x00\x12\'\n\x1cMETER_X_LifetimeEnergyImport!\x00\x00\x00\x00\xa8WeA\x12\'\n\x1cMETER_X_LifetimeEnergyExport!\x00\x00\x00\x80=\x13^A\x12\x17\n\x0cMETER_X_VL1N!\x85\xebQ\xb8\x1e5^@\x12\x17\n\x0cMETER_X_VL2N!\xf6(\\\x8f\xc2E^@\x12\x17\n\x0cMETER_X_VL3N!\x00\x00\x00\x00\x00\x00\x00\x00\x12\x18\n\rMETER_X_CTA_I!\xbdt\x93\x18\x04\xd6\x03@\x12\x18\n\rMETER_X_CTB_I!33333\xb3\x02@\x12\x18\n\rMETER_X_CTC_I!\x00\x00\x00\x00\x00\x00\x00\x00\x12$\n\x19METER_Y_CTA_InstRealPower!\x00\x00\x00\x00\x00\x00\x00\x00\x12$\n\x19METER_Y_CTB_InstRealPower!\x00\x00\x00\x00\x00\x00\x00\x00\x12$\n\x19METER_Y_CTC_InstRealPower!\x00\x00\x00\x00\x00\x00\x00\x00\x12(\n\x1dMETER_Y_CTA_InstReactivePower!\x00\x00\x00\x00\x00\x00\x00\x00\x12(\n\x1dMETER_Y_CTB_InstReactivePower!\x00\x00\x00\x00\x00\x00\x00\x00\x12(\n\x1dMETER_Y_CTC_InstReactivePower!\x00\x00\x00\x00\x00\x00\x00\x00\x12\'\n\x1cMETER_Y_LifetimeEnergyImport!\x00\x00\x00\x00\x00\x00\x00@\x12\'\n\x1cMETER_Y_LifetimeEnergyExport!\x00\x00\x00\x00\x00\x00\x00\x00\x12\x17\n\x0cMETER_Y_VL1N!\xf6(\\\x8f\xc25^@\x12\x17\n\x0cMETER_Y_VL2N!gffffF^@\x12\x17\n\x0cMETER_Y_VL3N!\x00\x00\x00\x00\x00\x00\x00\x00\x12\x18\n\rMETER_Y_CTA_I!\x00\x00\x00\x00\x00\x00\x00\x00\x12\x18\n\rMETER_Y_CTB_I!\x00\x00\x00\x00\x00\x00\x00\x00\x12\x18\n\rMETER_Y_CTC_I!\x00\x00\x00\x00\x00\x00\x00\x00\x1a\x15SYNC_a001_SW_App_Boot\x1a\x1aSYNC_a046_DoCloseArguments\n\x84\x01\n\x81\x01\n\x7f\n\x17\n\x15TESLA--J0000000000000\x1a\x10\n\x0eJ0000000000000"\x07\n\x05TESLA2&\n$STSTSM--1232100-00-E--T0000000000000:\x10\n\x0e4064fc6a5b3242J\x06\x08\xc8\xe4\xed\xae\x06R\x00Z\x05"\x03\n\x01\x01\n\xd9\x01\n\x90\x01\n\x8d\x01\n\x17\n\x15NEURIO--V000000000000\x1a\x0f\n\rV000000000000"\x08\n\x06NEURIO2&\n$STSTSM--1232100-00-E--T0000000000000:\r\n\x0b1.7.2-TeslaJ\x06\x08\xc8\xe4\xed\xae\x06R\x11\n\x0f\n\rPWRview-73533Z\x05"\x03\n\x01\x05\x12\x1f\n\x13NEURIO_CT0_Location*\x08solarRGM\x12#\n\x18NEURIO_CT0_InstRealPower!\x00\x00\x00\xa0pA\x9e@\n\xa1\x01\n\x9e\x01\n\x9b\x01\n%\n#TESLA--1538100-00-F--C0000000000000\x1a\x1e\n\x1c1538100-00-F--C0000000000000"\x07\n\x05TESLA2&\n$STSTSM--1232100-00-E--T0000000000000:\x10\n\x0e4064fc6a5b3242J\x06\x08\xc8\xe4\xed\xae\x06R\x00Z\x05\x1a\x03\x08\x80<' self.wfile.write(message) return - + # Unknown API - Simulator doesn't support API Requested else: - valid = False - if('cookie' in self.headers and '1234567890qwertyuiopasdfghjklZXcvbnm' in self.headers['cookie']): + session_valid = False + if 'cookie' in self.headers and '1234567890qwertyuiopasdfghjklZXcvbnm' in self.headers['cookie']: # Valid Login print("Simulator doesn't support API Requested: " + self.path) self.send_response(200) - self.send_header('Content-type','application/json') + self.send_header('Content-type', 'application/json') self.end_headers() message = '' - valid = True + session_valid = True # - # Error - if (not valid): + # Token Expired + if not session_valid: print(self.headers['cookie']) - self.send_response(403) - self.send_header('Content-type','application/json') + self.send_response(401) + self.send_header('Content-type', 'application/json') self.end_headers() - message = '{"code":403,"error":"Unable to GET to resource","message":"User does not have adequate access rights"}' + message = '{"code":401,"message":"Token Expired"}' print(message) # # Send Response self.wfile.write(bytes(message, "utf8")) + +# noinspection PyBroadException try: - with HTTPServer(server_address, handler) as server: + # noinspection PyTypeChecker + with HTTPServer(server_address, Handler) as server: server.socket = ssl.wrap_socket(server.socket, - server_side=True, - certfile='localhost.pem', - ssl_version=ssl.PROTOCOL_TLS) + server_side=True, + certfile='localhost.pem', + ssl_version=ssl.PROTOCOL_TLS) server.serve_forever() -except: +except Exception: print(' CANCEL \n') - - diff --git a/pypowerwall/local/pypowerwall_local.py b/pypowerwall/local/pypowerwall_local.py index 050e3de..64f643b 100644 --- a/pypowerwall/local/pypowerwall_local.py +++ b/pypowerwall/local/pypowerwall_local.py @@ -169,7 +169,6 @@ def poll(self, api: str, force: bool = False, # Rate limited - Switch to cooldown mode for 5 minutes self.pwcooldown = time.perf_counter() + 300 log.error('429 Rate limited by Powerwall API at %s - Activating 5 minute cooldown' % url) - # Serve up cached data if it exists (@emptywee: this doesn't look like we serve up cached data here?) return None elif r.status_code == 401: # Session Expired - Try to get a new one unless we already tried From 64e6a5d096db115df475ffdc4a1b6153944b947b Mon Sep 17 00:00:00 2001 From: Jason Cox Date: Wed, 27 Mar 2024 21:29:52 -0700 Subject: [PATCH 05/25] Add grid_status alerts --- pypowerwall/__init__.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/pypowerwall/__init__.py b/pypowerwall/__init__.py index 29cc207..2bba356 100644 --- a/pypowerwall/__init__.py +++ b/pypowerwall/__init__.py @@ -491,6 +491,17 @@ def alerts(self, jsonformat=False, alertsonly=True) -> Union[list, str]: if value is True: alerts.append(alert) + # Augment with inferred alerts from the grid_status + grid_status = self.poll('/api/system_status/grid_status') + if grid_status: + alert = grid_status.get('grid_status') + if alert == 'SystemGridConnected' and 'SystemConnectedToGrid' not in alerts: + alerts.append('SystemConnectedToGrid') + else: + alerts.append(alert) + if grid_status.get('grid_services_active'): + alerts.append('GridServicesActive') + if jsonformat: return json.dumps(alerts, indent=4, sort_keys=True) else: From 507f538a016adb6af2e98e5807247213ad8ed11d Mon Sep 17 00:00:00 2001 From: Jason Cox Date: Wed, 27 Mar 2024 22:18:45 -0700 Subject: [PATCH 06/25] Sim docker build-push action --- .github/workflows/pwsim-docker.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/workflows/pwsim-docker.yml diff --git a/.github/workflows/pwsim-docker.yml b/.github/workflows/pwsim-docker.yml new file mode 100644 index 0000000..9e57fbf --- /dev/null +++ b/.github/workflows/pwsim-docker.yml @@ -0,0 +1,30 @@ +name: pwsim-docker + +# Only trigger if a push is made to the pwsimulator folder +on: + push: + paths: + - 'pwsimulator/**' + +jobs: + docker: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64,linux/arm/v7 + push: true + tags: jasonacox/pwsimulator:latest \ No newline at end of file From f66a8e69c12a4da3c8b7d303dfef1cfcaad9a5d1 Mon Sep 17 00:00:00 2001 From: Igor Cherkaev Date: Thu, 28 Mar 2024 12:31:04 -0500 Subject: [PATCH 07/25] Add version/author information to pwsim ..and test the new gh workflow --- pwsimulator/stub.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pwsimulator/stub.py b/pwsimulator/stub.py index 7a7ddfb..3ab877d 100644 --- a/pwsimulator/stub.py +++ b/pwsimulator/stub.py @@ -25,9 +25,14 @@ import json import os + +version_tuple = (0, 0, 1) +version = __version__ = '%d.%d.%d' % version_tuple +__author__ = 'jasonacox' + # Create Simulator server_address = ('0.0.0.0', 443) -print('pyPowerwall - Powerwall Simulator - Running') +print(f'pyPowerwall - Powerwall Simulator v{__version__} by @{__author__} - Running') # Environmental Variables VITALS = os.getenv('VITALS', True) From 1aca735f60ff143273000e75e4a3269b32d9c253 Mon Sep 17 00:00:00 2001 From: Igor Cherkaev Date: Thu, 28 Mar 2024 12:44:39 -0500 Subject: [PATCH 08/25] Let us all build our own pwsim image :) --- .github/workflows/pwsim-docker.yml | 2 +- .github/workflows/simtest.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pwsim-docker.yml b/.github/workflows/pwsim-docker.yml index 9e57fbf..a27e86a 100644 --- a/.github/workflows/pwsim-docker.yml +++ b/.github/workflows/pwsim-docker.yml @@ -27,4 +27,4 @@ jobs: context: . platforms: linux/amd64,linux/arm64,linux/arm/v7 push: true - tags: jasonacox/pwsimulator:latest \ No newline at end of file + tags: ${{ secrets.DOCKERHUB_USERNAME }}/pwsimulator:latest diff --git a/.github/workflows/simtest.yml b/.github/workflows/simtest.yml index 786ea8a..98588ff 100644 --- a/.github/workflows/simtest.yml +++ b/.github/workflows/simtest.yml @@ -18,7 +18,7 @@ jobs: services: simulator: - image: jasonacox/pwsimulator + image: ${{ secrets.DOCKERHUB_USERNAME }}/pwsimulator ports: - 443:443 From eee6ca1b82aecb314d112034b227f313fcea2089 Mon Sep 17 00:00:00 2001 From: Igor Cherkaev Date: Thu, 28 Mar 2024 12:45:28 -0500 Subject: [PATCH 09/25] don't let .cachefile to leak to VCS --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 7c1d028..0840816 100644 --- a/.gitignore +++ b/.gitignore @@ -151,3 +151,5 @@ proxy/teslapy .fleetapi* .idea +**/.cachefile +.cachefile From 1e29ee8302ebe699c5ea1c70b63633408b988953 Mon Sep 17 00:00:00 2001 From: Igor Cherkaev Date: Thu, 28 Mar 2024 12:45:54 -0500 Subject: [PATCH 10/25] bump up pwsim version to test gh workflow --- pwsimulator/stub.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pwsimulator/stub.py b/pwsimulator/stub.py index 3ab877d..7c4c68a 100644 --- a/pwsimulator/stub.py +++ b/pwsimulator/stub.py @@ -26,7 +26,7 @@ import os -version_tuple = (0, 0, 1) +version_tuple = (0, 0, 2) version = __version__ = '%d.%d.%d' % version_tuple __author__ = 'jasonacox' From 1925b24d4b276ddc99427fa1cd33d70fe4e10884 Mon Sep 17 00:00:00 2001 From: Igor Cherkaev Date: Thu, 28 Mar 2024 12:48:45 -0500 Subject: [PATCH 11/25] context folder is pwsimulator/ I believe --- .github/workflows/pwsim-docker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pwsim-docker.yml b/.github/workflows/pwsim-docker.yml index a27e86a..fcd0800 100644 --- a/.github/workflows/pwsim-docker.yml +++ b/.github/workflows/pwsim-docker.yml @@ -24,7 +24,7 @@ jobs: - name: Build and push uses: docker/build-push-action@v5 with: - context: . + context: pwsimulator/ platforms: linux/amd64,linux/arm64,linux/arm/v7 push: true tags: ${{ secrets.DOCKERHUB_USERNAME }}/pwsimulator:latest From 0f6f9c6e354579aa178203b6ab1e6d89ec4e8802 Mon Sep 17 00:00:00 2001 From: Igor Cherkaev Date: Thu, 28 Mar 2024 12:50:32 -0500 Subject: [PATCH 12/25] bump up pwsim version to test gh workflow --- pwsimulator/stub.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pwsimulator/stub.py b/pwsimulator/stub.py index 7c4c68a..aca553e 100644 --- a/pwsimulator/stub.py +++ b/pwsimulator/stub.py @@ -26,7 +26,7 @@ import os -version_tuple = (0, 0, 2) +version_tuple = (0, 0, 3) version = __version__ = '%d.%d.%d' % version_tuple __author__ = 'jasonacox' From 739f842590868cad2a7b379a2f5f60e513736088 Mon Sep 17 00:00:00 2001 From: Igor Cherkaev Date: Thu, 28 Mar 2024 13:19:41 -0500 Subject: [PATCH 13/25] GitHub Workflow: can I use variables in image name for the containerized services? --- .github/workflows/simtest.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/simtest.yml b/.github/workflows/simtest.yml index 98588ff..a043bd3 100644 --- a/.github/workflows/simtest.yml +++ b/.github/workflows/simtest.yml @@ -5,6 +5,9 @@ on: pull_request: workflow_dispatch: +env: + IMAGE_REPOSITORY: ${{ secrets.DOCKERHUB_USERNAME }} + jobs: tests: name: "Python ${{ matrix.python-version }}" @@ -18,7 +21,7 @@ jobs: services: simulator: - image: ${{ secrets.DOCKERHUB_USERNAME }}/pwsimulator + image: ${{ env.IMAGE_REPOSITORY }}/pwsimulator ports: - 443:443 From 29521f44de53ced3ff9a90aad6c17a6ca7004443 Mon Sep 17 00:00:00 2001 From: Igor Cherkaev Date: Thu, 28 Mar 2024 13:23:46 -0500 Subject: [PATCH 14/25] GitHub Workflow: can I use variables in image name for the containerized services using repository vars? --- .github/workflows/simtest.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/simtest.yml b/.github/workflows/simtest.yml index a043bd3..73fcd53 100644 --- a/.github/workflows/simtest.yml +++ b/.github/workflows/simtest.yml @@ -5,9 +5,6 @@ on: pull_request: workflow_dispatch: -env: - IMAGE_REPOSITORY: ${{ secrets.DOCKERHUB_USERNAME }} - jobs: tests: name: "Python ${{ matrix.python-version }}" @@ -21,7 +18,7 @@ jobs: services: simulator: - image: ${{ env.IMAGE_REPOSITORY }}/pwsimulator + image: ${{ vars.IMAGE_REPOSITORY }}/pwsimulator ports: - 443:443 From ec2ec207a5c81c3095a9dabf008fc9a25702abf8 Mon Sep 17 00:00:00 2001 From: Igor Cherkaev Date: Fri, 29 Mar 2024 10:37:15 -0500 Subject: [PATCH 15/25] switch from vars.IMAGE_REPOSITORY to github.repository_owner for testing the simtest workflow --- .github/workflows/simtest.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/simtest.yml b/.github/workflows/simtest.yml index 73fcd53..2c80524 100644 --- a/.github/workflows/simtest.yml +++ b/.github/workflows/simtest.yml @@ -18,7 +18,7 @@ jobs: services: simulator: - image: ${{ vars.IMAGE_REPOSITORY }}/pwsimulator + image: ${{ github.repository_owner }}/pwsimulator ports: - 443:443 From cf38b81321d9150e57b7d97e2596d2d15c8eb7c3 Mon Sep 17 00:00:00 2001 From: Jason Cox Date: Sat, 30 Mar 2024 23:38:29 -0700 Subject: [PATCH 16/25] Add args to set/get ops modes, reserve and power data --- pypowerwall/__main__.py | 82 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/pypowerwall/__main__.py b/pypowerwall/__main__.py index 776f2bf..76b895c 100644 --- a/pypowerwall/__main__.py +++ b/pypowerwall/__main__.py @@ -14,6 +14,7 @@ import argparse import os import sys +import json # Modules from pypowerwall import version @@ -46,6 +47,20 @@ scan_args.add_argument("-hosts", type=int, default=hosts, help=f"Number of hosts to scan simultaneously [Default={hosts}]") +set_mode_args = subparsers.add_parser("set", help='Set Powerwall Mode and Reserve Level') +set_mode_args.add_argument("-mode", type=str, default="self_consumption", + help="Powerwall Mode: self_consumption, backup, or autonomous") +set_mode_args.add_argument("-reserve", type=int, default=20, + help="Set Battery Reserve Level [Default=20]") +set_mode_args.add_argument("-current", action="store_true", default=False, + help="Set Battery Reserve Level to Current Charge") + +get_mode_args = subparsers.add_parser("get", help='Get Powerwall Settings and Power Levels') +get_mode_args.add_argument("-format", type=str, default="text", + help="Output format: text, json, csv") + +version_args = subparsers.add_parser("version", help='Print version information') + if len(sys.argv) == 1: p.print_help(sys.stderr) sys.exit(1) @@ -77,6 +92,73 @@ hosts = args.hosts timeout = args.timeout scan.scan(color, timeout, hosts, ip) +# Set Powerwall Mode +elif command == 'set': + import pypowerwall + print("pyPowerwall [%s] - Set Powerwall Mode and Power Levels\n" % version) + # Load email from auth file + auth_file = authpath + AUTHFILE + if not os.path.exists(auth_file): + print("ERROR: Auth file %s not found. Run 'setup' to create." % auth_file) + exit(1) + with open(auth_file, 'r') as file: + auth = json.load(file) + email = list(auth.keys())[0] + pw = pypowerwall.Powerwall(email=email, host="", authpath=authpath) + if args.mode: + mode = args.mode.lower() + reserve = args.reserve + if mode not in ['self_consumption', 'backup', 'autonomous']: + print("ERROR: Invalid Mode [%s] - must be one of self_consumption, backup, or autonomous" % mode) + exit(1) + pw.set_mode(mode, reserve) + if args.reserve: + reserve = args.reserve + pw.set_reserve(reserve) + if args.current: + current = float(pw.level()) + pw.set_reserve(current) +# Get Powerwall Mode +elif command == 'get': + import pypowerwall + # Load email from auth file + auth_file = authpath + AUTHFILE + if not os.path.exists(auth_file): + print("ERROR: Auth file %s not found. Run 'setup' to create." % auth_file) + exit(1) + with open(auth_file, 'r') as file: + auth = json.load(file) + email = list(auth.keys())[0] + pw = pypowerwall.Powerwall(email=email, host="", authpath=authpath) + output = { + 'site': pw.site_name(), + 'din': pw.din(), + 'mode': pw.get_mode(), + 'reserve': pw.get_reserve(), + 'current': pw.level(), + 'grid': pw.grid(), + 'home': pw.home(), + 'battery': pw.battery(), + 'solar': pw.solar(), + } + if args.format == 'json': + print(json.dumps(output, indent=2)) + elif args.format == 'csv': + # create a csv header from keys + header = ",".join(output.keys()) + print(header) + values = ",".join(str(value) for value in output.values()) + print(values) + else: + print("pyPowerwall [%s] - Set Powerwall Mode and Power Levels\n" % version) + # Table Output + for item in output: + name = item.replace("_", " ").title() + print(" {:<15}{}".format(name, output[item])) + +# Print Version +elif command == 'version': + print("pyPowerwall [%s]" % version) # Print Usage else: p.print_help() From 4d7d5ffdfcf864c42df352acf3c75612eb8992a5 Mon Sep 17 00:00:00 2001 From: Jason Cox Date: Sat, 30 Mar 2024 23:46:43 -0700 Subject: [PATCH 17/25] Fix 'set' and add output --- pypowerwall/__main__.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/pypowerwall/__main__.py b/pypowerwall/__main__.py index 76b895c..409983a 100644 --- a/pypowerwall/__main__.py +++ b/pypowerwall/__main__.py @@ -48,9 +48,9 @@ help=f"Number of hosts to scan simultaneously [Default={hosts}]") set_mode_args = subparsers.add_parser("set", help='Set Powerwall Mode and Reserve Level') -set_mode_args.add_argument("-mode", type=str, default="self_consumption", +set_mode_args.add_argument("-mode", type=str, default=None, help="Powerwall Mode: self_consumption, backup, or autonomous") -set_mode_args.add_argument("-reserve", type=int, default=20, +set_mode_args.add_argument("-reserve", type=int, default=None, help="Set Battery Reserve Level [Default=20]") set_mode_args.add_argument("-current", action="store_true", default=False, help="Set Battery Reserve Level to Current Charge") @@ -107,16 +107,18 @@ pw = pypowerwall.Powerwall(email=email, host="", authpath=authpath) if args.mode: mode = args.mode.lower() - reserve = args.reserve if mode not in ['self_consumption', 'backup', 'autonomous']: print("ERROR: Invalid Mode [%s] - must be one of self_consumption, backup, or autonomous" % mode) exit(1) - pw.set_mode(mode, reserve) + print("Setting Powerwall Mode to %s" % mode) + pw.set_mode(mode) if args.reserve: reserve = args.reserve + print("Setting Powerwall Reserve to %s" % reserve) pw.set_reserve(reserve) if args.current: current = float(pw.level()) + print("Setting Powerwall Reserve to Current Charge Level %s" % current) pw.set_reserve(current) # Get Powerwall Mode elif command == 'get': From 2202da209c3b147fd1a0cda08a4b050ec8d8dd47 Mon Sep 17 00:00:00 2001 From: Jason Cox Date: Sun, 31 Mar 2024 00:56:42 -0700 Subject: [PATCH 18/25] Treat 403 as Session Expired --- pypowerwall/local/pypowerwall_local.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/pypowerwall/local/pypowerwall_local.py b/pypowerwall/local/pypowerwall_local.py index 64f643b..42bec9a 100644 --- a/pypowerwall/local/pypowerwall_local.py +++ b/pypowerwall/local/pypowerwall_local.py @@ -170,7 +170,7 @@ def poll(self, api: str, force: bool = False, self.pwcooldown = time.perf_counter() + 300 log.error('429 Rate limited by Powerwall API at %s - Activating 5 minute cooldown' % url) return None - elif r.status_code == 401: + elif r.status_code == 401 or r.status_code == 403: # Session Expired - Try to get a new one unless we already tried log.debug('Session Expired - Trying to get a new one') if not recursive: @@ -181,15 +181,14 @@ def poll(self, api: str, force: bool = False, self._get_session() return self.poll(api, raw=raw, recursive=True) else: - log.error('Unable to establish session with Powerwall at %s - check password' % url) + if r.status_code == 401: + log.error('Unable to establish session with Powerwall at %s - check password' % url) + else: + log.error('403 Unauthorized by Powerwall API at %s - Endpoint disabled in this firmware or ' + 'user lacks permission' % url) + self.pwcachetime[api] = time.perf_counter() + 600 + self.pwcache[api] = None return None - elif r.status_code == 403: - # Unauthorized - log.error('403 Unauthorized by Powerwall API at %s - Endpoint disabled in this firmware or ' - 'user lacks permission' % url) - self.pwcachetime[api] = time.perf_counter() + 600 - self.pwcache[api] = None - return None elif 400 <= r.status_code < 500: log.error('Unhandled HTTP response code %s at %s' % (r.status_code, url)) return None From 014758b4b37b528b745502ac9eb503246172b65b Mon Sep 17 00:00:00 2001 From: Jason Cox Date: Sun, 31 Mar 2024 01:21:04 -0700 Subject: [PATCH 19/25] Beta uploader --- proxy/Dockerfile.beta | 7 +++++++ proxy/beta.txt | 5 +++++ proxy/upload-beta.sh | 41 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+) create mode 100644 proxy/Dockerfile.beta create mode 100644 proxy/beta.txt create mode 100755 proxy/upload-beta.sh diff --git a/proxy/Dockerfile.beta b/proxy/Dockerfile.beta new file mode 100644 index 0000000..c98b337 --- /dev/null +++ b/proxy/Dockerfile.beta @@ -0,0 +1,7 @@ +FROM python:3.10-alpine +WORKDIR /app +COPY beta.txt /app/requirements.txt +RUN pip3 install -r requirements.txt +COPY . . +CMD ["python3", "server.py"] +EXPOSE 8675 diff --git a/proxy/beta.txt b/proxy/beta.txt new file mode 100644 index 0000000..a10da43 --- /dev/null +++ b/proxy/beta.txt @@ -0,0 +1,5 @@ +bs4==0.0.2 +requests +protobuf +teslapy + diff --git a/proxy/upload-beta.sh b/proxy/upload-beta.sh new file mode 100755 index 0000000..abceed5 --- /dev/null +++ b/proxy/upload-beta.sh @@ -0,0 +1,41 @@ +#!/bin/bash +echo "Build and Push jasonacox/pypowerwall to Docker Hub" +echo "" + +last_path=$(basename $PWD) +if [ "$last_path" == "proxy" ]; then + # Remove test link + rm -rf pypowerwall + cp -r ../pypowerwall . + + # Determine version + PROXY=`grep "BUILD = " server.py | cut -d\" -f2` + PYPOWERWALL=`echo -n "import pypowerwall +print(pypowerwall.version)" | (cd ..; python3)` + VER="${PYPOWERWALL}${PROXY}-beta${1}" + + # Check with user before proceeding + echo "Build and push jasonacox/pypowerwall:${VER} to Docker Hub?" + read -p "Press [Enter] to continue or Ctrl-C to cancel..." + + # Build jasonacox/pypowerwall:x.y.z + echo "* BUILD jasonacox/pypowerwall:${VER}" + docker buildx build -f Dockerfile.beta --no-cache --platform linux/amd64,linux/arm64,linux/arm/v7 --push -t jasonacox/pypowerwall:${VER} . + echo "" + + # Verify + echo "* VERIFY jasonacox/pypowerwall:${VER}" + docker buildx imagetools inspect jasonacox/pypowerwall:${VER} | grep Platform + echo "" + echo "* VERIFY jasonacox/pypowerwall:latest" + docker buildx imagetools inspect jasonacox/pypowerwall | grep Platform + echo "" + + # Restore link for testing + ln -s ../pypowerwall pypowerwall + +else + # Exit script if last_path is not "proxy" + echo "Current directory is not 'proxy'." + exit 0 +fi From 5165345cb6df64358bb3a7b5938ce8b85c132ef0 Mon Sep 17 00:00:00 2001 From: Jason Cox Date: Sun, 31 Mar 2024 01:21:42 -0700 Subject: [PATCH 20/25] Fix cachefile location --- proxy/server.py | 9 +++++++-- pypowerwall/__init__.py | 14 ++++++++------ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/proxy/server.py b/proxy/server.py index b2640f4..9a044db 100755 --- a/proxy/server.py +++ b/proxy/server.py @@ -46,7 +46,7 @@ from pypowerwall import parse_version from transform import get_static, inject_js -BUILD = "t51" +BUILD = "t52" ALLOWLIST = [ '/api/status', '/api/site_info/site_name', '/api/meters/site', '/api/meters/solar', '/api/sitemaster', '/api/powerwalls', @@ -77,6 +77,10 @@ siteid = os.getenv("PW_SITEID", None) authpath = os.getenv("PW_AUTH_PATH", "") authmode = os.getenv("PW_AUTH_MODE", "cookie") +cf = ".powerwall" +if authpath: + cf = os.path.join(authpath, ".powerwall") +cachefile = os.getenv("PW_CACHE_FILE", cf) # Global Stats proxystats = { @@ -148,7 +152,8 @@ def get_value(a, key): try: pw = pypowerwall.Powerwall(host, password, email, timezone, cache_expire, timeout, pool_maxsize, siteid=siteid, - authpath=authpath, authmode=authmode) + authpath=authpath, authmode=authmode, + cachefile=cachefile) except Exception as e: log.error(e) log.error("Fatal Error: Unable to connect. Please fix config and restart.") diff --git a/pypowerwall/__init__.py b/pypowerwall/__init__.py index 2bba356..947411b 100644 --- a/pypowerwall/__init__.py +++ b/pypowerwall/__init__.py @@ -742,28 +742,30 @@ def _validate_init_configuration(self): # Ensure we can write to the provided authpath dirname = self.authpath if not dirname: + log.debug("No authpath provided, using current directory.") dirname = '.' - self._check_if_dir_is_writable(dirname) + self._check_if_dir_is_writable(dirname, "authpath") # If local mode, check appropriate parameters, too else: # Ensure we can create a cachefile dirname = os.path.dirname(self.cachefile) if not dirname: + log.debug("No cachefile provided, using current directory.") dirname = '.' - self._check_if_dir_is_writable(dirname) + self._check_if_dir_is_writable(dirname, "cachefile") @staticmethod - def _check_if_dir_is_writable(dirpath): + def _check_if_dir_is_writable(dirpath, name=""): # Ensure we can write to the provided authpath if not os.path.exists(dirpath): try: os.makedirs(dirpath, exist_ok=True) except Exception as exc: - raise PyPowerwallInvalidConfigurationParameter(f"Unable to create directory at " + raise PyPowerwallInvalidConfigurationParameter(f"Unable to create {name} directory at " f"'{dirpath}': {exc}") elif not os.path.isdir(dirpath): - raise PyPowerwallInvalidConfigurationParameter(f"'{dirpath}' must be a directory.") + raise PyPowerwallInvalidConfigurationParameter(f"'{dirpath}' must be a directory ({name}).") else: if not os.access(dirpath, os.W_OK): - raise PyPowerwallInvalidConfigurationParameter(f"Directory '{dirpath}' is not writable. " + raise PyPowerwallInvalidConfigurationParameter(f"Directory '{dirpath}' is not writable for {name}. " f"Check permissions.") From c46b479b62fcdd3c419be8151d5b20c2cbcf5110 Mon Sep 17 00:00:00 2001 From: Igor Cherkaev Date: Tue, 2 Apr 2024 23:51:31 -0500 Subject: [PATCH 21/25] pass `force` flag all the way down to `_site_api()` of the Cloud module, invalidate respective cache data on write operations --- api_test.py | 2 + pypowerwall/cloud/pypowerwall_cloud.py | 164 +++++++++++++++---------- pypowerwall/local/pypowerwall_local.py | 4 +- pypowerwall/pypowerwall_base.py | 11 ++ 4 files changed, 116 insertions(+), 65 deletions(-) diff --git a/api_test.py b/api_test.py index d7b4d72..899f571 100644 --- a/api_test.py +++ b/api_test.py @@ -75,7 +75,9 @@ def run(include_post_funcs=False): # Connect to Powerwall pw = pypowerwall.Powerwall(h, password, email, timezone, authpath=auth_path, cachefile=cachefile_path) + # noinspection PyUnusedLocal aggregates = pw.poll('/api/meters/aggregates') + # noinspection PyUnusedLocal coe = pw.poll('/api/system_status/soe') # Pull Sensor Power Data diff --git a/pypowerwall/cloud/pypowerwall_cloud.py b/pypowerwall/cloud/pypowerwall_cloud.py index ea0bc07..6043852 100644 --- a/pypowerwall/cloud/pypowerwall_cloud.py +++ b/pypowerwall/cloud/pypowerwall_cloud.py @@ -2,9 +2,9 @@ import logging import os import time -from typing import Optional, Union +from typing import Optional, Union, List -from teslapy import Tesla +from teslapy import Tesla, Battery, SolarPanel from pypowerwall.cloud.decorators import not_implemented_mock_data from pypowerwall.cloud.exceptions import * @@ -55,7 +55,6 @@ def __init__(self, email: Optional[str], pwcacheexpire: int = 5, timeout: int = self.tesla = None self.apilock = {} # holds lock flag for pending cloud api requests self.pwcachetime = {} # holds the cached data timestamps for api - self.pwcache = {} # holds the cached data for api self.pwcacheexpire = pwcacheexpire # seconds to expire cache self.siteindex = 0 # site index to use self.siteid = siteid # site id to use @@ -195,7 +194,12 @@ def poll(self, api: str, force: bool = False, func = self.poll_api_map.get(api) if func: - return func() + kwargs = { + 'force': force, + 'recursive': recursive, + 'raw': raw + } + return func(**kwargs) else: # raise PyPowerwallCloudNotImplemented(api) # or pass a custom error response: @@ -217,13 +221,17 @@ def post(self, api: str, payload: Optional[dict], din: Optional[str], 'payload': payload, 'din': din } - return func(**kwargs) + res = func(**kwargs) + if res: + # invalidate appropriate read cache on (more or less) successful call to writable API + super()._invalidate_cache(api) + return res else: # raise PyPowerwallCloudNotImplemented(api) # or pass a custom error response: return {"ERROR": f"Unknown API: {api}"} - def getsites(self): + def getsites(self) -> Optional[List[Union[Battery, SolarPanel]]]: """ Get list of Tesla Energy sites """ @@ -264,7 +272,7 @@ def change_site(self, siteid): # Functions to get data from Tesla Cloud - def _site_api(self, name, ttl, **kwargs): + def _site_api(self, name: str, ttl: int, force: bool, **kwargs): """ Private function to get site data from Tesla Cloud using TeslaPy API. This function uses a lock to prevent threads @@ -274,6 +282,7 @@ def _site_api(self, name, ttl, **kwargs): Arguments: name - TeslaPy API name ttl - Cache expiration time in seconds + force - If True skip cache kwargs - Variable arguments to pass to API call Returns (response, cached) @@ -289,10 +298,10 @@ def _site_api(self, name, ttl, **kwargs): while self.apilock[name]: time.sleep(0.2) if time.perf_counter() >= locktime + self.timeout: - log.debug(f" -- cloud: Timeout waiting for {name}") + log.debug(f" -- cloud: Timeout waiting for {name} (unable to acquire lock)") return None, False # Check to see if we have cached data - if name in self.pwcache: + if name in self.pwcache and not force: if self.pwcachetime[name] > time.perf_counter() - ttl: log.debug(f" -- cloud: Returning cached {name} data") return self.pwcache[name], True @@ -313,7 +322,7 @@ def _site_api(self, name, ttl, **kwargs): self.apilock[name] = False return response, False - def get_battery(self): + def get_battery(self, force: bool = False): """ Get site battery data from Tesla Cloud @@ -337,11 +346,10 @@ def get_battery(self): } """ # GET api/1/energy_sites/{site_id}/site_status - (response, _) = self._site_api("SITE_SUMMARY", - self.pwcacheexpire, language="en") + (response, _) = self._site_api("SITE_SUMMARY", self.pwcacheexpire, language="en", force=force) return response - def get_site_power(self): + def get_site_power(self, force: bool = False): """ Get site power data from Tesla Cloud @@ -365,16 +373,18 @@ def get_site_power(self): } """ # GET api/1/energy_sites/{site_id}/live_status?counter={counter}&language=en - (response, cached) = self._site_api("SITE_DATA", - self.pwcacheexpire, counter=self.counter + 1, language="en") + (response, cached) = self._site_api("SITE_DATA", self.pwcacheexpire, counter=self.counter + 1, + language="en", force=force) if not cached: self.counter = (self.counter + 1) % COUNTER_MAX return response - def get_site_config(self): + def get_site_config(self, force: bool = False): """ Get site configuration data from Tesla Cloud + Args: + force: if True, skip cache "response": { "id": "1232100-00-E--TGxxxxxxxxxxxx", "site_name": "Tesla Energy Gateway", @@ -459,19 +469,18 @@ def get_site_config(self): """ # GET api/1/energy_sites/{site_id}/site_info - (response, _) = self._site_api("SITE_CONFIG", - SITE_CONFIG_TTL, language="en") + (response, _) = self._site_api("SITE_CONFIG", SITE_CONFIG_TTL, language="en", force=force) return response - def get_time_remaining(self) -> Optional[float]: + def get_time_remaining(self, force: bool = False) -> Optional[float]: """ Get backup time remaining from Tesla Cloud {'response': {'time_remaining_hours': 7.909122698326978}} """ # GET api/1/energy_sites/{site_id}/backup_time_remaining - (response, _) = self._site_api("ENERGY_SITE_BACKUP_TIME_REMAINING", - self.pwcacheexpire, language="en") + (response, _) = self._site_api("ENERGY_SITE_BACKUP_TIME_REMAINING", self.pwcacheexpire, language="en", + force=force) # {'response': {'time_remaining_hours': 7.909122698326978}} if response is None or not isinstance(response, dict): @@ -481,8 +490,9 @@ def get_time_remaining(self) -> Optional[float]: return 0.0 - def get_api_system_status_soe(self) -> Optional[Union[dict, list, str, bytes]]: - battery = self.get_battery() + def get_api_system_status_soe(self, **kwargs) -> Optional[Union[dict, list, str, bytes]]: + force = kwargs.get('force', False) + battery = self.get_battery(force=force) if battery is None: data = None else: @@ -494,9 +504,10 @@ def get_api_system_status_soe(self) -> Optional[Union[dict, list, str, bytes]]: } return data - def get_api_status(self) -> Optional[Union[dict, list, str, bytes]]: + def get_api_status(self, **kwargs) -> Optional[Union[dict, list, str, bytes]]: # TOOO: Fix start_time and up_time_seconds - config = self.get_site_config() + force = kwargs.get('force', False) + config = self.get_site_config(force=force) if config is None: data = None else: @@ -516,8 +527,9 @@ def get_api_status(self) -> Optional[Union[dict, list, str, bytes]]: } return data - def get_api_system_status_grid_status(self) -> Optional[Union[dict, list, str, bytes]]: - power = self.get_site_power() + def get_api_system_status_grid_status(self, **kwargs) -> Optional[Union[dict, list, str, bytes]]: + force = kwargs.get('force', False) + power = self.get_site_power(force=force) if power is None: data = None else: @@ -532,8 +544,9 @@ def get_api_system_status_grid_status(self) -> Optional[Union[dict, list, str, b } return data - def get_api_site_info_site_name(self) -> Optional[Union[dict, list, str, bytes]]: - config = self.get_site_config() + def get_api_site_info_site_name(self, **kwargs) -> Optional[Union[dict, list, str, bytes]]: + force = kwargs.get('force', False) + config = self.get_site_config(force=force) if config is None: data = None else: @@ -545,8 +558,9 @@ def get_api_site_info_site_name(self) -> Optional[Union[dict, list, str, bytes]] } return data - def get_api_site_info(self) -> Optional[Union[dict, list, str, bytes]]: - config = self.get_site_config() + def get_api_site_info(self, **kwargs) -> Optional[Union[dict, list, str, bytes]]: + force = kwargs.get('force', False) + config = self.get_site_config(force=force) if config is None: data = None else: @@ -579,16 +593,18 @@ def get_api_site_info(self) -> Optional[Union[dict, list, str, bytes]]: } return data - def get_api_devices_vitals(self) -> Optional[Union[dict, list, str, bytes]]: + # noinspection PyUnusedLocal + def get_api_devices_vitals(self, **kwargs) -> Optional[Union[dict, list, str, bytes]]: # Protobuf payload - not implemented - use /vitals instead data = None log.warning("Protobuf payload - not implemented for /api/devices/vitals - use /vitals instead") return data - def get_vitals(self) -> Optional[Union[dict, list, str, bytes]]: + def get_vitals(self, **kwargs) -> Optional[Union[dict, list, str, bytes]]: # Simulated Vitals - config = self.get_site_config() - power = self.get_site_power() + force = kwargs.get('force', False) + config = self.get_site_config(force=force) + power = self.get_site_power(force=force) if config is None or power is None: data = None else: @@ -632,9 +648,10 @@ def get_vitals(self) -> Optional[Union[dict, list, str, bytes]]: } return data - def get_api_meters_aggregates(self) -> Optional[Union[dict, list, str, bytes]]: - config = self.get_site_config() - power = self.get_site_power() + def get_api_meters_aggregates(self, **kwargs) -> Optional[Union[dict, list, str, bytes]]: + force = kwargs.get('force', False) + config = self.get_site_config(force=force) + power = self.get_site_power(force=force) if config is None or power is None: data = None else: @@ -673,8 +690,9 @@ def get_api_meters_aggregates(self) -> Optional[Union[dict, list, str, bytes]]: }) return data - def get_api_operation(self) -> Optional[Union[dict, list, str, bytes]]: - config = self.get_site_config() + def get_api_operation(self, **kwargs) -> Optional[Union[dict, list, str, bytes]]: + force = kwargs.get('force', False) + config = self.get_site_config(force) if config is None: data = None else: @@ -688,10 +706,11 @@ def get_api_operation(self) -> Optional[Union[dict, list, str, bytes]]: } return data - def get_api_system_status(self) -> Optional[Union[dict, list, str, bytes]]: - power = self.get_site_power() - config = self.get_site_config() - battery = self.get_battery() + def get_api_system_status(self, **kwargs) -> Optional[Union[dict, list, str, bytes]]: + force = kwargs.get('force', False) + power = self.get_site_power(force=force) + config = self.get_site_config(force=force) + battery = self.get_battery(force=force) if power is None or config is None or battery is None: data = None else: @@ -730,84 +749,103 @@ def get_api_system_status(self) -> Optional[Union[dict, list, str, bytes]]: return data + # noinspection PyUnusedLocal @not_implemented_mock_data - def api_logout(self) -> Optional[Union[dict, list, str, bytes]]: + def api_logout(self, **kwargs) -> Optional[Union[dict, list, str, bytes]]: return {"status": "ok"} + # noinspection PyUnusedLocal @not_implemented_mock_data - def api_login_basic(self) -> Optional[Union[dict, list, str, bytes]]: + def api_login_basic(self, **kwargs) -> Optional[Union[dict, list, str, bytes]]: return {"status": "ok"} + # noinspection PyUnusedLocal @not_implemented_mock_data - def get_api_meters_site(self) -> Optional[Union[dict, list, str, bytes]]: + def get_api_meters_site(self, **kwargs) -> Optional[Union[dict, list, str, bytes]]: return json.loads(METERS_SITE) + # noinspection PyUnusedLocal @not_implemented_mock_data - def get_unimplemented_api(self) -> Optional[Union[dict, list, str, bytes]]: + def get_unimplemented_api(self, **kwargs) -> Optional[Union[dict, list, str, bytes]]: return None + # noinspection PyUnusedLocal @not_implemented_mock_data - def get_api_unimplemented_timeout(self) -> Optional[Union[dict, list, str, bytes]]: + def get_api_unimplemented_timeout(self, **kwargs) -> Optional[Union[dict, list, str, bytes]]: return "TIMEOUT!" + # noinspection PyUnusedLocal @not_implemented_mock_data - def get_api_auth_toggle_supported(self) -> Optional[Union[dict, list, str, bytes]]: + def get_api_auth_toggle_supported(self, **kwargs) -> Optional[Union[dict, list, str, bytes]]: return {"toggle_auth_supported": True} + # noinspection PyUnusedLocal @not_implemented_mock_data - def get_api_sitemaster(self) -> Optional[Union[dict, list, str, bytes]]: + def get_api_sitemaster(self, **kwargs) -> Optional[Union[dict, list, str, bytes]]: return {"status": "StatusUp", "running": True, "connected_to_tesla": True, "power_supply_mode": False, "can_reboot": "Yes"} + # noinspection PyUnusedLocal @not_implemented_mock_data - def get_api_powerwalls(self) -> Optional[Union[dict, list, str, bytes]]: + def get_api_powerwalls(self, **kwargs) -> Optional[Union[dict, list, str, bytes]]: return json.loads(POWERWALLS) + # noinspection PyUnusedLocal @not_implemented_mock_data - def get_api_customer_registration(self) -> Optional[Union[dict, list, str, bytes]]: + def get_api_customer_registration(self, **kwargs) -> Optional[Union[dict, list, str, bytes]]: return json.loads('{"privacy_notice":null,"limited_warranty":null,"grid_services":null,"marketing":null,' '"registered":true,"timed_out_registration":false}') + # noinspection PyUnusedLocal @not_implemented_mock_data - def get_api_system_update_status(self) -> Optional[Union[dict, list, str, bytes]]: + def get_api_system_update_status(self, **kwargs) -> Optional[Union[dict, list, str, bytes]]: return json.loads('{"state":"/update_succeeded","info":{"status":["nonactionable"]},' '"current_time":1702756114429,"last_status_time":1702753309227,"version":"23.28.2 27626f98",' '"offline_updating":false,"offline_update_error":"","estimated_bytes_per_second":null}') + # noinspection PyUnusedLocal @not_implemented_mock_data - def get_api_system_status_grid_faults(self) -> Optional[Union[dict, list, str, bytes]]: + def get_api_system_status_grid_faults(self, **kwargs) -> Optional[Union[dict, list, str, bytes]]: return json.loads('[]') + # noinspection PyUnusedLocal @not_implemented_mock_data - def get_api_solars(self) -> Optional[Union[dict, list, str, bytes]]: + def get_api_solars(self, **kwargs) -> Optional[Union[dict, list, str, bytes]]: return json.loads('[{"brand":"Tesla","model":"Solar Inverter 7.6","power_rating_watts":7600}]') + # noinspection PyUnusedLocal @not_implemented_mock_data - def get_api_solars_brands(self) -> Optional[Union[dict, list, str, bytes]]: + def get_api_solars_brands(self, **kwargs) -> Optional[Union[dict, list, str, bytes]]: return json.loads(SOLARS_BRANDS) + # noinspection PyUnusedLocal @not_implemented_mock_data - def get_api_customer(self) -> Optional[Union[dict, list, str, bytes]]: + def get_api_customer(self, **kwargs) -> Optional[Union[dict, list, str, bytes]]: return {"registered": True} + # noinspection PyUnusedLocal @not_implemented_mock_data - def get_api_meters(self) -> Optional[Union[dict, list, str, bytes]]: + def get_api_meters(self, **kwargs) -> Optional[Union[dict, list, str, bytes]]: return json.loads(METERS) + # noinspection PyUnusedLocal @not_implemented_mock_data - def get_api_installer(self) -> Optional[Union[dict, list, str, bytes]]: + def get_api_installer(self, **kwargs) -> Optional[Union[dict, list, str, bytes]]: return json.loads(INSTALLER) + # noinspection PyUnusedLocal @not_implemented_mock_data - def get_api_synchrometer_ct_voltage_references(self) -> Optional[Union[dict, list, str, bytes]]: + def get_api_synchrometer_ct_voltage_references(self, **kwargs) -> Optional[Union[dict, list, str, bytes]]: return json.loads('{"ct1":"Phase1","ct2":"Phase2","ct3":"Phase1"}') + # noinspection PyUnusedLocal @not_implemented_mock_data - def get_api_troubleshooting_problems(self) -> Optional[Union[dict, list, str, bytes]]: + def get_api_troubleshooting_problems(self, **kwargs) -> Optional[Union[dict, list, str, bytes]]: return json.loads('{"problems":[]}') + # noinspection PyUnusedLocal @not_implemented_mock_data - def get_api_solar_powerwall(self) -> Optional[Union[dict, list, str, bytes]]: + def get_api_solar_powerwall(self, **kwargs) -> Optional[Union[dict, list, str, bytes]]: return {} def setup(self, email=None): diff --git a/pypowerwall/local/pypowerwall_local.py b/pypowerwall/local/pypowerwall_local.py index 42bec9a..167d0ae 100644 --- a/pypowerwall/local/pypowerwall_local.py +++ b/pypowerwall/local/pypowerwall_local.py @@ -29,7 +29,6 @@ def __init__(self, host: str, password: str, email: str, timezone: str, timeout: self.session = None self.pwcachetime = {} # holds the cached data timestamps for api - self.pwcache = {} # holds the cached data for api self.pwcacheexpire = pwcacheexpire # seconds to expire cache self.pwcooldown = 0 # rate limit cooldown time - pause api calls self.vitals_api = True # vitals api is available for local mode @@ -286,7 +285,8 @@ def post(self, api: str, payload: Optional[dict], din: Optional[str], return None else: log.debug(f"Non-json response from Powerwall at {url}: '{response}', serving as is.") - + # invalidate appropriate read cache on (more or less) successful call to writable API + super()._invalidate_cache(api) return response def version(self, int_value=False): diff --git a/pypowerwall/pypowerwall_base.py b/pypowerwall/pypowerwall_base.py index 6870eab..7e98e87 100644 --- a/pypowerwall/pypowerwall_base.py +++ b/pypowerwall/pypowerwall_base.py @@ -4,6 +4,11 @@ log = logging.getLogger(__name__) +# Define which write API calls should invalidate which read API cache keys +WRITE_OP_READ_OP_CACHE_MAP = { + '/api/operation': ['/api/operation', 'SITE_CONFIG'] # local and cloud mode respectively +} + def parse_version(version: str) -> Optional[int]: if version is None or not isinstance(version, str): @@ -23,6 +28,7 @@ class PyPowerwallBase: def __init__(self, email: str): super().__init__() + self.pwcache = {} # holds the cached data for api self.auth = None self.token = None # caches bearer token self.email = email @@ -75,3 +81,8 @@ def power(self) -> dict: except Exception as e: log.debug(f"ERROR unable to parse payload '{payload}': {e}") return {'site': site, 'solar': solar, 'battery': battery, 'load': load} + + def _invalidate_cache(self, api: str): + cache_keys = WRITE_OP_READ_OP_CACHE_MAP.get(api, []) + for cache_key in cache_keys: + self.pwcache[cache_key] = None From 16b2c928e98a437344b498d38a44496759cc633c Mon Sep 17 00:00:00 2001 From: Jason Cox Date: Wed, 3 Apr 2024 22:02:27 -0700 Subject: [PATCH 22/25] Add debug for local polling --- pypowerwall/local/pypowerwall_local.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pypowerwall/local/pypowerwall_local.py b/pypowerwall/local/pypowerwall_local.py index 167d0ae..ebe6e8b 100644 --- a/pypowerwall/local/pypowerwall_local.py +++ b/pypowerwall/local/pypowerwall_local.py @@ -119,6 +119,7 @@ def poll(self, api: str, force: bool = False, # is it expired? if time.perf_counter() - self.pwcachetime[api] < self.pwcacheexpire: payload = self.pwcache[api] + log.debug(' -- local: Returning cached %s' % api) # We do the override here to ensure that we cache the force entry if not payload or force: @@ -133,6 +134,7 @@ def poll(self, api: str, force: bool = False, # Always want the raw stream output from the vitals call; protobuf binary payload raw = True + log.debug(' -- local: Request Powerwall for %s' % api) url = "https://%s%s" % (self.host, api) try: if self.authmode == "token": From 03dfa1bd4946c763d370cb5ff27eb58abeb938b7 Mon Sep 17 00:00:00 2001 From: Igor Cherkaev Date: Thu, 4 Apr 2024 00:47:24 -0500 Subject: [PATCH 23/25] properly check if we have a cached response --- pypowerwall/cloud/pypowerwall_cloud.py | 2 +- pypowerwall/local/pypowerwall_local.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pypowerwall/cloud/pypowerwall_cloud.py b/pypowerwall/cloud/pypowerwall_cloud.py index 6043852..92c24c1 100644 --- a/pypowerwall/cloud/pypowerwall_cloud.py +++ b/pypowerwall/cloud/pypowerwall_cloud.py @@ -301,7 +301,7 @@ def _site_api(self, name: str, ttl: int, force: bool, **kwargs): log.debug(f" -- cloud: Timeout waiting for {name} (unable to acquire lock)") return None, False # Check to see if we have cached data - if name in self.pwcache and not force: + if self.pwcache.get(name) is not None and not force: if self.pwcachetime[name] > time.perf_counter() - ttl: log.debug(f" -- cloud: Returning cached {name} data") return self.pwcache[name], True diff --git a/pypowerwall/local/pypowerwall_local.py b/pypowerwall/local/pypowerwall_local.py index ebe6e8b..918dc4a 100644 --- a/pypowerwall/local/pypowerwall_local.py +++ b/pypowerwall/local/pypowerwall_local.py @@ -115,7 +115,7 @@ def poll(self, api: str, force: bool = False, raw = False payload = None # Check cache - if api in self.pwcache and api in self.pwcachetime: + if api in self.pwcache and self.pwcachetime.get(api) is not None: # is it expired? if time.perf_counter() - self.pwcachetime[api] < self.pwcacheexpire: payload = self.pwcache[api] From b85fc6f419bccb58a84fccebe7961d2a26c1774a Mon Sep 17 00:00:00 2001 From: Igor Cherkaev Date: Thu, 4 Apr 2024 00:47:24 -0500 Subject: [PATCH 24/25] properly check if we have a cached response --- pypowerwall/cloud/pypowerwall_cloud.py | 2 +- pypowerwall/local/pypowerwall_local.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pypowerwall/cloud/pypowerwall_cloud.py b/pypowerwall/cloud/pypowerwall_cloud.py index 6043852..92c24c1 100644 --- a/pypowerwall/cloud/pypowerwall_cloud.py +++ b/pypowerwall/cloud/pypowerwall_cloud.py @@ -301,7 +301,7 @@ def _site_api(self, name: str, ttl: int, force: bool, **kwargs): log.debug(f" -- cloud: Timeout waiting for {name} (unable to acquire lock)") return None, False # Check to see if we have cached data - if name in self.pwcache and not force: + if self.pwcache.get(name) is not None and not force: if self.pwcachetime[name] > time.perf_counter() - ttl: log.debug(f" -- cloud: Returning cached {name} data") return self.pwcache[name], True diff --git a/pypowerwall/local/pypowerwall_local.py b/pypowerwall/local/pypowerwall_local.py index ebe6e8b..125ce64 100644 --- a/pypowerwall/local/pypowerwall_local.py +++ b/pypowerwall/local/pypowerwall_local.py @@ -115,7 +115,7 @@ def poll(self, api: str, force: bool = False, raw = False payload = None # Check cache - if api in self.pwcache and api in self.pwcachetime: + if self.pwcache.get(api) is not None and self.pwcachetime.get(api) is not None: # is it expired? if time.perf_counter() - self.pwcachetime[api] < self.pwcacheexpire: payload = self.pwcache[api] From c7252ac50e72436bd4ad812c4e15c959c76fbf56 Mon Sep 17 00:00:00 2001 From: Jason Cox Date: Thu, 4 Apr 2024 20:53:28 -0700 Subject: [PATCH 25/25] Doc updates v0.8.1 --- README.md | 54 ++++++++++++++++++++++++++++++++++++++++---- RELEASE.md | 25 ++++++++++++++++---- proxy/upload-beta.sh | 1 + 3 files changed, 71 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index c9dd094..4442c9b 100644 --- a/README.md +++ b/README.md @@ -169,8 +169,8 @@ and call function to poll data. Here is an example: is_connected() # Returns True if able to connect to Powerwall get_reserve(scale) # Get Battery Reserve Percentage get_mode() # Get Current Battery Operation Mode - set_reserve(level) # Set Battery Reserve Percentage - set_mode(mode) # Set Current Battery Operation Mode + set_reserve(level) # Set Battery Reserve Percentage (only cloud mode) + set_mode(mode) # Set Current Battery Operation Mode (only cloud mode) get_time_remaining() # Get the backup time remaining on the battery set_operation(level, mode, json) # Set Battery Reserve Percentage and/or Operation Mode @@ -187,10 +187,27 @@ The following are some useful tools based on pypowerwall: * [Powerwall Dashboard](https://github.com/jasonacox/Powerwall-Dashboard#powerwall-dashboard) - Monitoring Dashboard for the Tesla Powerwall using Grafana, InfluxDB, Telegraf and pyPowerwall. -## Powerwall Scanner +## Powerwall Command Line -pyPowerwall has a built in feature to scan your network for available Powerwall gateways. This will help you find the IP address of your Powerwall. +pyPowerwall has a built in feature to scan your network for available Powerwall gateways and set/get operational and reserve modes. +``` +Usage: PyPowerwall [-h] {setup,scan,set,get,version} ... + +PyPowerwall Module v0.8.1 + +Options: + -h, --help Show this help message and exit + +Commands (run -h to see usage information): + {setup,scan,set,get,version} + setup Setup Tesla Login for Cloud Mode access + scan Scan local network for Powerwall gateway + set Set Powerwall Mode and Reserve Level + get Get Powerwall Settings and Power Levels + version Print version information +``` + ```bash # Install pyPowerwall if you haven't already python -m pip install pypowerwall @@ -218,6 +235,35 @@ Discovered 1 Powerwall Gateway 10.0.1.36 [1232100-00-E--TG123456789ABG] ``` +Get Power Levels, Operation Mode, and Battery Reserve Level + +```bash +# Setup Connection with Tesla Cloud +python -m pypowerwall setup + +# Get Power Levels, Operation Mode, and Battery Reserve Setting +# +# Usage: PyPowerwall get [-h] [-format FORMAT] +# -h, --help show this help message and exit +# -format FORMAT Output format: text, json, csv +# +python -m pypowerwall get +python -m pypowerwall get -format json +python -m pypowerwall get -format csv + +# Set Operation Mode and Battery Reserve Setting +# +# Usage: PyPowerwall set [-h] [-mode MODE] [-reserve RESERVE] [-current] +# -h, --help show this help message and exit +# -mode MODE Powerwall Mode: self_consumption, backup, or autonomous +# -reserve RESERVE Set Battery Reserve Level [Default=20] +# -current Set Battery Reserve Level to Current Charge +# +python -m pypowerwall set -mode self_consumption +python -m pypowerwall set -reserve 30 +python -m pypowerwall set -current +``` + ## Example API Calls The following APIs are a result of help from other projects as well as my own investigation. diff --git a/RELEASE.md b/RELEASE.md index f124735..b05f7d6 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -2,13 +2,28 @@ ## v0.8.1 - Set battery reserve, operation mode -* Added `get_mode()` function. -* Added `set_mode()` function. -* Added `set_reserve()` function. -* Added `set_operation()` function to set battery operation mode and/or reserve level. Likely won't work in the local mode. +* Added `get_mode()`, `set_mode()`,`set_reserve()`,and `set_operation()` function to set battery operation mode and/or reserve level by @emptywee in https://github.com/jasonacox/pypowerwall/pull/78. Likely won't work in the local mode. * Added basic validation for main class `__init__()` parameters (a.k.a. user input). -* Handle 401/403 errors from Powerwall separately in local mode. +* Better handling of 401/403 errors from Powerwall in local mode. * Handle 50x errors from Powerwall in local mode. +* New command line functions (`set` and `get`): + +``` +usage: PyPowerwall [-h] {setup,scan,set,get,version} ... + +PyPowerwall Module v0.8.1 + +options: + -h, --help show this help message and exit + +commands (run -h to see usage information): + {setup,scan,set,get,version} + setup Setup Tesla Login for Cloud Mode access + scan Scan local network for Powerwall gateway + set Set Powerwall Mode and Reserve Level + get Get Powerwall Settings and Power Levels + version Print version information +``` ## v0.8.0 - Refactoring diff --git a/proxy/upload-beta.sh b/proxy/upload-beta.sh index abceed5..deca47c 100755 --- a/proxy/upload-beta.sh +++ b/proxy/upload-beta.sh @@ -32,6 +32,7 @@ print(pypowerwall.version)" | (cd ..; python3)` echo "" # Restore link for testing + rm -rf pypowerwall ln -s ../pypowerwall pypowerwall else