diff --git a/RELEASE.md b/RELEASE.md index d9000e1..c358b1f 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,5 +1,11 @@ # RELEASE NOTES +## v0.10.5 - Minor Fixes + +* Fix for TEDAPI "full" (e.g. Powerwall 3) mode, including `grid_status` bug resulting in false reports of grid status, `level()` bug where data gap resulted in 0% state of charge and `alerts()` where data gap from tedapi resulted in a `null` alert. +* Add TEDAPI API call locking to limit load caused by concurrent polling. +* Proxy - Add battery full_pack and remaining energy data to `/pod` API call for all cases. + ## v0.10.4 - Powerwall 3 Local API Support * Add local support for Powerwall 3 using TEDAPI. diff --git a/proxy/RELEASE.md b/proxy/RELEASE.md index 14890bc..9142be8 100644 --- a/proxy/RELEASE.md +++ b/proxy/RELEASE.md @@ -1,5 +1,9 @@ ## pyPowerwall Proxy Release Notes +### Proxy t62 (13 Jun 2024) + +* Add battery full_pack and remaining energy data to `/pod` API call for all cases. + ### Proxy t61 (9 Jun 2024) * Fix 404 bug that would throw error when user requested non-supported URI. diff --git a/proxy/requirements.txt b/proxy/requirements.txt index 55a5b39..eaab2cc 100644 --- a/proxy/requirements.txt +++ b/proxy/requirements.txt @@ -1,2 +1,2 @@ -pypowerwall==0.10.4 +pypowerwall==0.10.5 bs4==0.0.2 diff --git a/proxy/server.py b/proxy/server.py index 6c3ebd6..df6bdb5 100755 --- a/proxy/server.py +++ b/proxy/server.py @@ -55,7 +55,7 @@ from transform import get_static, inject_js from urllib.parse import urlparse, parse_qs -BUILD = "t61" +BUILD = "t62" ALLOWLIST = [ '/api/status', '/api/site_info/site_name', '/api/meters/site', '/api/meters/solar', '/api/sitemaster', '/api/powerwalls', @@ -497,10 +497,8 @@ def do_GET(self): pod["PW%d_POD_nom_full_pack_energy" % idx] = get_value(v, 'POD_nom_full_pack_energy') idx = idx + 1 # Aggregate data - if pod: - # Only poll if we have battery data - pod["nominal_full_pack_energy"] = get_value(d, 'nominal_full_pack_energy') - pod["nominal_energy_remaining"] = get_value(d, 'nominal_energy_remaining') + pod["nominal_full_pack_energy"] = get_value(d, 'nominal_full_pack_energy') + pod["nominal_energy_remaining"] = get_value(d, 'nominal_energy_remaining') pod["time_remaining_hours"] = pw.get_time_remaining() pod["backup_reserve_percent"] = pw.get_reserve() message: str = json.dumps(pod) diff --git a/pypowerwall/__init__.py b/pypowerwall/__init__.py index 0e85de4..67b71e3 100644 --- a/pypowerwall/__init__.py +++ b/pypowerwall/__init__.py @@ -84,7 +84,7 @@ from typing import Union, Optional import time -version_tuple = (0, 10, 4) +version_tuple = (0, 10, 5) version = __version__ = '%d.%d.%d' % version_tuple __author__ = 'jasonacox' @@ -606,7 +606,7 @@ def alerts(self, jsonformat=False, alertsonly=True) -> Union[list, str]: alert = grid_status.get('grid_status') if alert == 'SystemGridConnected' and 'SystemConnectedToGrid' not in alerts: alerts.append('SystemConnectedToGrid') - else: + elif alert: alerts.append(alert) if grid_status.get('grid_services_active'): alerts.append('GridServicesActive') diff --git a/pypowerwall/tedapi/__init__.py b/pypowerwall/tedapi/__init__.py index bb5ef34..7d468cc 100644 --- a/pypowerwall/tedapi/__init__.py +++ b/pypowerwall/tedapi/__init__.py @@ -89,6 +89,7 @@ def __init__(self, gw_pwd: str, debug: bool = False, pwcacheexpire: int = 5, tim self.gw_ip = host self.din = None self.pw3 = False # Powerwall 3 Gateway only supports TEDAPI + self.apilock = {} # holds the api lock status if not gw_pwd: raise ValueError("Missing gw_pwd") if self.debug: @@ -177,6 +178,14 @@ def get_config(self,force=False): "vin": "1232100-00-E--TG11234567890" } """ + # Check for lock and wait if api request already sent + if 'config' in self.apilock: + locktime = time.perf_counter() + while self.apilock['config']: + time.sleep(0.2) + if time.perf_counter() >= locktime + self.timeout: + log.debug(f" -- tedapi: Timeout waiting for config (unable to acquire lock)") + return None # Check Cache if not force and "config" in self.pwcachetime: if time.time() - self.pwcachetime["config"] < self.pwconfigexpire: @@ -202,30 +211,41 @@ def get_config(self,force=False): pb.message.config.send.file = "config.json" pb.tail.value = 1 url = f'https://{self.gw_ip}/tedapi/v1' - r = requests.post(url, auth=('Tesla_Energy_Device', self.gw_pwd), verify=False, - headers={'Content-type': 'application/octet-string'}, - data=pb.SerializeToString()) - log.debug(f"Response Code: {r.status_code}") - if r.status_code in BUSY_CODES: - # Rate limited - Switch to cooldown mode for 5 minutes - self.pwcooldown = time.perf_counter() + 300 - log.error('Possible Rate limited by Powerwall at - Activating 5 minute cooldown') - return None - if r.status_code != 200: - log.error(f"Error fetching config: {r.status_code}") - return None - # Decode response - tedapi = tedapi_pb2.Message() - tedapi.ParseFromString(r.content) - payload = tedapi.message.config.recv.file.text try: - data = json.loads(payload) - except json.JSONDecodeError as e: - log.error(f"Error Decoding JSON: {e}") - data = {} - log.debug(f"Configuration: {data}") - self.pwcachetime["config"] = time.time() - self.pwcache["config"] = data + # Set lock + self.apilock['config'] = True + r = requests.post(url, auth=('Tesla_Energy_Device', self.gw_pwd), verify=False, + headers={'Content-type': 'application/octet-string'}, + data=pb.SerializeToString()) + log.debug(f"Response Code: {r.status_code}") + if r.status_code in BUSY_CODES: + # Rate limited - Switch to cooldown mode for 5 minutes + self.pwcooldown = time.perf_counter() + 300 + log.error('Possible Rate limited by Powerwall at - Activating 5 minute cooldown') + self.apilock['config'] = False + return None + if r.status_code != 200: + log.error(f"Error fetching config: {r.status_code}") + self.apilock['config'] = False + return None + # Decode response + tedapi = tedapi_pb2.Message() + tedapi.ParseFromString(r.content) + payload = tedapi.message.config.recv.file.text + try: + data = json.loads(payload) + except json.JSONDecodeError as e: + log.error(f"Error Decoding JSON: {e}") + data = {} + log.debug(f"Configuration: {data}") + self.pwcachetime["config"] = time.time() + self.pwcache["config"] = data + except Exception as e: + log.error(f"Error fetching config: {e}") + data = None + finally: + # Release lock + self.apilock['config'] = False return data def get_status(self, force=False): @@ -269,6 +289,14 @@ def get_status(self, force=False): } """ + # Check for lock and wait if api request already sent + if 'status' in self.apilock: + locktime = time.perf_counter() + while self.apilock['status']: + time.sleep(0.2) + if time.perf_counter() >= locktime + self.timeout: + log.debug(f" -- tedapi: Timeout waiting for status (unable to acquire lock)") + return None # Check Cache if not force and "status" in self.pwcachetime: if time.time() - self.pwcachetime["status"] < self.pwcacheexpire: @@ -297,30 +325,41 @@ def get_status(self, force=False): pb.message.payload.send.b.value = "{}" pb.tail.value = 1 url = f'https://{self.gw_ip}/tedapi/v1' - r = requests.post(url, auth=('Tesla_Energy_Device', self.gw_pwd), verify=False, - headers={'Content-type': 'application/octet-string'}, - data=pb.SerializeToString()) - log.debug(f"Response Code: {r.status_code}") - if r.status_code in BUSY_CODES: - # Rate limited - Switch to cooldown mode for 5 minutes - self.pwcooldown = time.perf_counter() + 300 - log.error('Possible Rate limited by Powerwall at - Activating 5 minute cooldown') - return None - if r.status_code != 200: - log.error(f"Error fetching status: {r.status_code}") - return None - # Decode response - tedapi = tedapi_pb2.Message() - tedapi.ParseFromString(r.content) - payload = tedapi.message.payload.recv.text try: - data = json.loads(payload) - except json.JSONDecodeError as e: - log.error(f"Error Decoding JSON: {e}") - data = {} - log.debug(f"Status: {data}") - self.pwcachetime["status"] = time.time() - self.pwcache["status"] = data + # Set lock + self.apilock['status'] = True + r = requests.post(url, auth=('Tesla_Energy_Device', self.gw_pwd), verify=False, + headers={'Content-type': 'application/octet-string'}, + data=pb.SerializeToString()) + log.debug(f"Response Code: {r.status_code}") + if r.status_code in BUSY_CODES: + # Rate limited - Switch to cooldown mode for 5 minutes + self.pwcooldown = time.perf_counter() + 300 + log.error('Possible Rate limited by Powerwall at - Activating 5 minute cooldown') + self.apilock['status'] = False + return None + if r.status_code != 200: + log.error(f"Error fetching status: {r.status_code}") + self.apilock['status'] = False + return None + # Decode response + tedapi = tedapi_pb2.Message() + tedapi.ParseFromString(r.content) + payload = tedapi.message.payload.recv.text + try: + data = json.loads(payload) + except json.JSONDecodeError as e: + log.error(f"Error Decoding JSON: {e}") + data = {} + log.debug(f"Status: {data}") + self.pwcachetime["status"] = time.time() + self.pwcache["status"] = data + except Exception as e: + log.error(f"Error fetching status: {e}") + data = None + finally: + # Release lock + self.apilock['status'] = False return data def connect(self): diff --git a/pypowerwall/tedapi/pypowerwall_tedapi.py b/pypowerwall/tedapi/pypowerwall_tedapi.py index 6dcad7a..3e6309c 100644 --- a/pypowerwall/tedapi/pypowerwall_tedapi.py +++ b/pypowerwall/tedapi/pypowerwall_tedapi.py @@ -172,7 +172,9 @@ def get_time_remaining(self, force: bool = False) -> Optional[float]: def get_api_system_status_soe(self, **kwargs) -> Optional[Union[dict, list, str, bytes]]: force = kwargs.get('force', False) - percentage_charged = self.tedapi.battery_level(force=force) or 0 + percentage_charged = self.tedapi.battery_level(force=force) + if not percentage_charged: + return None data = { "percentage": percentage_charged } @@ -203,8 +205,10 @@ def get_api_status(self, **kwargs) -> Optional[Union[dict, list, str, bytes]]: def get_api_system_status_grid_status(self, **kwargs) -> Optional[Union[dict, list, str, bytes]]: force = kwargs.get('force', False) status = self.tedapi.get_status(force=force) - alerts = lookup(status, ["control", "alerts", "active"]) or [] - if "SystemConnectedToGrid" in alerts: + grid_state = lookup(status, ["esCan", "bus", "ISLANDER", "ISLAND_GridConnection", "ISLAND_GridConnected"]) + if not grid_state: + return None + if grid_state == "ISLAND_GridConnected_Connected": grid_status = "SystemGridConnected" else: grid_status = "SystemIslandedActive" @@ -347,8 +351,10 @@ def get_api_system_status(self, **kwargs) -> Optional[Union[dict, list, str, byt if not isinstance(status, dict) or not isinstance(config, dict): return None status = self.tedapi.get_status(force=force) - alerts = lookup(status, ["control", "alerts", "active"]) - if "SystemConnectedToGrid" in alerts: + grid_state = lookup(status, ["esCan", "bus", "ISLANDER", "ISLAND_GridConnection", "ISLAND_GridConnected"]) + if not grid_state: + grid_status = None + elif grid_state == "ISLAND_GridConnected_Connected": grid_status = "SystemGridConnected" else: grid_status = "SystemIslandedActive"