From cc967e9247880c4704ad618c02b78688b51f3abc Mon Sep 17 00:00:00 2001 From: Jason Cox Date: Sun, 9 Jun 2024 12:31:38 -0700 Subject: [PATCH] v0.10.2 #97 --- README.md | 18 +++- RELEASE.md | 16 +++ proxy/RELEASE.md | 4 + proxy/server.py | 4 +- pypowerwall/__main__.py | 11 +++ pypowerwall/tedapi/__init__.py | 53 +++++++--- pypowerwall/tedapi/__main__.py | 172 +++++++++++++++++++++++---------- requirements.txt | 2 +- setup.py | 2 +- 9 files changed, 211 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index b65a454..e789707 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,23 @@ python3 -m pypowerwall setup ### Local Setup - Option 1 -The Tesla Powerwall, Powerwall 2 and Powerwall+ have a local LAN based API that you can use to monitor your Powerwall. It requires that you (or your installer) have the IP address (see scan above) and set up *Customer Login* credentials on your Powerwall Gateway. That is all that is needed to connect. Unfortunately, Powerwall 3 does not have a local API but you can access it via the cloud (options 2 and 3). +The Tesla Powerwall, Powerwall 2 and Powerwall+ have a local LAN based API that you can use to monitor your Powerwall. It requires that you (or your installer) have the IP address (see scan above) and set up *Customer Login* credentials on your Powerwall Gateway. That is all that is needed to connect. Unfortunately, the Powerwall 3 does not have a local API but you can access it via the cloud (see options 2 and 3). + +Extended Device Vitals Metrics: With version v0.10.0+, pypowerwall can be set to access the TEDAPI on the Gateway to pull additional metrics. However, you will need the Gateway Password (often found on the QR sticker on the Powerwall Gateway). Additionally, your computer will need network access to the Gateway IP (192.168.91.1). You can have your computer join the Gateway local WiFi or you can add a route: + +```bash +# Example - Change 192.168.0.100 to the IP address of Powerwall Gateway on your LAN + +# Linux Ubuntu and RPi - Can add to /etc/rc.local for persistence +sudo ip route add 192.168.91.1 via 192.168.0.100 + +# MacOS +sudo route add -host 192.168.91.1 192.168.0.100 # Temporary +networksetup -setadditionalroutes Wi-Fi 192.168.91.1 255.255.255.255 192.168.0.100 # Persistent + +# Windows - Using persistence flag - Administrator Shell +route -p add 192.168.91.1 mask 255.255.255.255 192.168.0.100 +``` ### FleetAPI Setup - Option 2 diff --git a/RELEASE.md b/RELEASE.md index 9b5d04c..a316317 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,5 +1,21 @@ # RELEASE NOTES +## v0.10.3 - TEDAPI Connect + +* Update `setup.py` to include dependencies on `protobuf>=3.20.0`. +* Add TEDAPI `connect()` logic to better validate Gateway endpoint access. +* Add documentation for TEDAPI setup. +* Update CLI to support TEDAPI calls. + +```bash +# Connect to TEDAPI and pull data +python3 -m pypowerwall tedapi + +# Direct call to TEDAPI class test function (optional password) +python3 -m pypowerwall.tedapi GWPASSWORD +python3 -m pypowerwall.tedapi --debug +``` + ## v0.10.2 - FleetAPI Hotfix * Fix FleetAPI setup script as raised in https://github.com/jasonacox/pypowerwall/issues/98. diff --git a/proxy/RELEASE.md b/proxy/RELEASE.md index 727a7e7..e253d31 100644 --- a/proxy/RELEASE.md +++ b/proxy/RELEASE.md @@ -1,5 +1,9 @@ ## pyPowerwall Proxy Release Notes +### Proxy t60 (9 Jun 2024) + +* Add error handling for `/csv` API to accommodate `None` data points. + ### Proxy t59 (8 Jun 2024) * Minor fix to send less ambiguous debug information during client disconnects. diff --git a/proxy/server.py b/proxy/server.py index c24fccf..500b18c 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 = "t59" +BUILD = "t60" ALLOWLIST = [ '/api/status', '/api/site_info/site_name', '/api/meters/site', '/api/meters/solar', '/api/sitemaster', '/api/powerwalls', @@ -330,7 +330,7 @@ def do_GET(self): elif self.path == '/csv': # Grid,Home,Solar,Battery,Level - CSV contenttype = 'text/plain; charset=utf-8' - batterylevel = pw.level() + batterylevel = pw.level() or 0 grid = pw.grid() or 0 solar = pw.solar() or 0 battery = pw.battery() or 0 diff --git a/pypowerwall/__main__.py b/pypowerwall/__main__.py index 9db2738..cb42ece 100644 --- a/pypowerwall/__main__.py +++ b/pypowerwall/__main__.py @@ -41,6 +41,8 @@ setup_args = subparsers.add_parser("fleetapi", help='Setup Tesla FleetAPI for Cloud Mode access') +setup_args = subparsers.add_parser("tedapi", help='Test TEDAPI connection to Powerwall Gateway') + scan_args = subparsers.add_parser("scan", help='Scan local network for Powerwall gateway') scan_args.add_argument("-timeout", type=float, default=timeout, help=f"Seconds to wait per host [Default={timeout:.1f}]") @@ -96,6 +98,7 @@ else: print("ERROR: Failed to setup Tesla Cloud Mode") exit(1) + # FleetAPI Mode Setup elif command == 'fleetapi': from pypowerwall import PyPowerwallFleetAPI @@ -108,6 +111,12 @@ else: print("Setup Aborted.") exit(1) + +# TEDAPI Test +elif command == 'tedapi': + from pypowerwall.tedapi.__main__ import run_tedapi_test + run_tedapi_test(auto=True, debug=args.debug) + # Run Scan elif command == 'scan': from pypowerwall import scan @@ -118,6 +127,7 @@ hosts = args.hosts timeout = args.timeout scan.scan(color, timeout, hosts, ip) + # Set Powerwall Mode elif command == 'set': # If no arguments, print usage @@ -146,6 +156,7 @@ current = float(pw.level()) print("Setting Powerwall Reserve to Current Charge Level %s" % current) pw.set_reserve(current) + # Get Powerwall Mode elif command == 'get': import pypowerwall diff --git a/pypowerwall/tedapi/__init__.py b/pypowerwall/tedapi/__init__.py index f0006ee..6fff8e5 100644 --- a/pypowerwall/tedapi/__init__.py +++ b/pypowerwall/tedapi/__init__.py @@ -39,6 +39,7 @@ import time from pypowerwall import __version__ import math +import sys # TEDAPI Fixed Gateway IP Address GW_IP = "192.168.91.1" @@ -48,6 +49,8 @@ # Setup Logging log = logging.getLogger(__name__) +log.debug('%s version %s', __name__, __version__) +log.debug('Python %s on %s', sys.version, sys.platform) # Utility Functions def lookup(data, keylist): @@ -64,7 +67,6 @@ def lookup(data, keylist): return data # TEDAPI Class - class TEDAPI: def __init__(self, gw_pwd, debug=False, pwcacheexpire: int = 5, timeout: int = 5, pwconfigexpire: int = 300) -> None: self.debug = debug @@ -78,15 +80,26 @@ def __init__(self, gw_pwd, debug=False, pwcacheexpire: int = 5, timeout: int = 5 if not gw_pwd: raise ValueError("Missing gw_pwd") if self.debug: - log.setLevel(logging.DEBUG) + self.set_debug(True) self.gw_pwd = gw_pwd # Connect to Powerwall Gateway if not self.connect(): log.error("Failed to connect to Powerwall Gateway") - raise ValueError("Failed to connect to Powerwall Gateway") - + # TEDAPI Functions + def set_debug(toggle=True, color=True): + """Enable verbose logging""" + if toggle: + if color: + logging.basicConfig(format='\x1b[31;1m%(levelname)s:%(message)s\x1b[0m', level=logging.DEBUG) + else: + logging.basicConfig(format='%(levelname)s:%(message)s', level=logging.DEBUG) + log.setLevel(logging.DEBUG) + log.debug("%s [%s]\n" % (__name__, __version__)) + else: + log.setLevel(logging.NOTSET) + def get_din(self, force=False): """ Get the DIN from the Powerwall Gateway @@ -109,6 +122,9 @@ def get_din(self, force=False): self.pwcooldown = time.perf_counter() + 300 log.error('Possible Rate limited by Powerwall at - Activating 5 minute cooldown') return None + if r.status_code == 403: + log.error("Access Denied: Check your Gateway Password") + return None if r.status_code != 200: log.error(f"Error fetching DIN: {r.status_code}") return None @@ -158,6 +174,11 @@ def get_config(self,force=False): # Rate limited - return None log.debug('Rate limit cooldown period - Pausing API calls') return None + # Check Connection + if not self.din: + if not self.connect(): + log.error("Not Connected - Unable to get configuration") + return None # Fetch Configuration from Powerwall log.debug("Get Configuration from Powerwall") # Build Protobuf to fetch config @@ -245,6 +266,11 @@ def get_status(self, force=False): # Rate limited - return None log.debug('Rate limit cooldown period - Pausing API calls') return None + # Check Connection + if not self.din: + if not self.connect(): + log.error("Not Connected - Unable to get status") + return None # Fetch Current Status from Powerwall log.debug("Get Status from Powerwall") # Build Protobuf to fetch status @@ -292,18 +318,15 @@ def connect(self): # Test IP Connection to Powerwall Gateway log.debug(f"Testing Connection to Powerwall Gateway: {GW_IP}") url = f'https://{GW_IP}' - try: - r = requests.get(url, verify=False, timeout=5) - except requests.exceptions.RequestException as e: - r = False - log.error("ERROR: Powerwall not Found", - f"Try: sudo route add -host {GW_IP}") - if r: - # Attempt to fetch DIN from Powerwall - self.din = self.get_din() - return True self.din = None - return False + try: + _ = requests.get(url, verify=False, timeout=5) + self.din = self.get_din() + except Exception as e: + log.error(f"Unable to connect to Powerwall Gateway {GW_IP}") + log.error("Please verify your your host has a route to the Gateway.") + log.error(f"Error Details: {e}") + return self.din # Handy Function to access Powerwall Status diff --git a/pypowerwall/tedapi/__main__.py b/pypowerwall/tedapi/__main__.py index 645f326..59e0e67 100644 --- a/pypowerwall/tedapi/__main__.py +++ b/pypowerwall/tedapi/__main__.py @@ -1,4 +1,4 @@ -# pyPowerWall - Tesla TEDAPI Class Main +# pyPowerwall - Tesla TEDAPI Class Main # -*- coding: utf-8 -*- """ Tesla TEADAPI Class - Command Line Test @@ -6,60 +6,130 @@ This script tests the TEDAPI class by connecting to a Tesla Powerwall Gateway """ -# Imports -from pypowerwall.tedapi import TEDAPI, GW_IP -import json -import sys +def run_tedapi_test(auto=False, debug=False): + # Print header + print("pyPowerwall - Powerwall Gateway TEDAPI Reader") -# Print Header -print("Tesla Powerwall Gateway TEDAPI Reader") + # Imports + from pypowerwall.tedapi import TEDAPI, GW_IP + from pypowerwall import __version__ + import json + import sys + import argparse + import requests + import logging -# Command line arguments -if len(sys.argv) > 1: - gw_pwd = sys.argv[1] -else: - # Get GW_PWD from User - gw_pwd = input("\nEnter Powerwall Gateway Password: ") -print() + # Setup Logging + log = logging.getLogger(__name__) -# Create TEDAPI Object and get Configuration and Status -print(f"Connecting to Powerwall Gateway {GW_IP}") -ted = TEDAPI(gw_pwd) -config = ted.get_config() -status = ted.get_status() -print() + def set_debug(toggle=True, color=True): + """Enable verbose logging""" + if toggle: + if color: + logging.basicConfig(format='\x1b[31;1m%(levelname)s:%(message)s\x1b[0m', level=logging.DEBUG) + else: + logging.basicConfig(format='%(levelname)s:%(message)s', level=logging.DEBUG) + log.setLevel(logging.DEBUG) + log.debug('pyPowerwall TEDAPI version %s', __version__) + log.debug('Python %s on %s', sys.version, sys.platform) + else: + log.setLevel(logging.NOTSET) -# Print Configuration -print(" - Configuration:") -site_info = config.get('site_info', {}) -site_name = site_info.get('site_name', 'Unknown') -print(f" - Site Name: {site_name}") -battery_commission_date = site_info.get('battery_commission_date', 'Unknown') -print(f" - Battery Commission Date: {battery_commission_date}") -vin = config.get('vin', 'Unknown') -print(f" - VIN: {vin}") -number_of_powerwalls = len(config.get('battery_blocks', [])) -print(f" - Number of Powerwalls: {number_of_powerwalls}") -print() + # Load arguments if invoked from pypowerwall + if auto: + argv = ['pypowerwall'] + if debug: + argv.append('--debug') + sys.argv = argv -# Print power data -print(" - Power Data:") -nominalEnergyRemainingWh = status.get('control', {}).get('systemStatus', {}).get('nominalEnergyRemainingWh', 0) -nominalFullPackEnergyWh = status.get('control', {}).get('systemStatus', {}).get('nominalFullPackEnergyWh', 0) -soe = round(nominalEnergyRemainingWh / nominalFullPackEnergyWh * 100, 2) -print(f" - Battery Charge: {soe}% ({nominalEnergyRemainingWh}Wh of {nominalFullPackEnergyWh}Wh)") -meterAggregates = status.get('control', {}).get('meterAggregates', []) -for meter in meterAggregates: - location = meter.get('location', 'Unknown').title() - realPowerW = int(meter.get('realPowerW', 0)) - print(f" - {location}: {realPowerW}W") -print() + # Check for arguments using argparse + parser = argparse.ArgumentParser(description='Tesla Powerwall Gateway TEDAPI Reader') + parser.add_argument('gw_pwd', nargs='?', help='Powerwall Gateway Password') + # Add debug + parser.add_argument('--debug', action='store_true', help='Enable Debug Output') + # Parse arguments + args = parser.parse_args() + if args.gw_pwd: + gw_pwd = args.gw_pwd + else: + gw_pwd = None + if args.debug: + set_debug(True) -# Save Configuration and Status to JSON files -with open('status.json', 'w') as f: - json.dump(status, f) -with open('config.json', 'w') as f: - json.dump(config, f) -print(" - Configuration and Status saved to config.json and status.json") -print() + # Check that GW_IP is listening to port 443 + url = f'https://{GW_IP}' + log.debug(f"Checking Powerwall Gateway at {url}") + print(f" - Connecting to {url}...", end="") + try: + resp = requests.get(url, verify=False, timeout=5) + log.debug(f"Connection to Powerwall Gateway successful, code {resp.status_code}.") + print(f" SUCCESS") + except Exception as e: + print(" FAILED") + print() + print(f"ERROR: Unable to connect to Powerwall Gateway {GW_IP} on port 443.") + print("Please verify your your host has a route to the Gateway.") + print(f"\nError details: {e}") + sys.exit(1) + # Get GW_PWD from User if not provided + if gw_pwd is None: + while not gw_pwd: + try: + gw_pwd = input("\nEnter Powerwall Gateway Password: ") + except KeyboardInterrupt: + print("") + sys.exit(1) + except Exception as e: + print(f"Error: {e}") + sys.exit(1) + if not gw_pwd: + print("Password Required") + + # Create TEDAPI Object and get Configuration and Status + print() + print(f"Connecting to Powerwall Gateway {GW_IP}") + ted = TEDAPI(gw_pwd) + if ted.din is None: + print("\nERROR: Unable to connect to Powerwall Gateway. Check your password and try again") + sys.exit(1) + config = ted.get_config() + status = ted.get_status() + print() + + # Print Configuration + print(" - Configuration:") + site_info = config.get('site_info', {}) + site_name = site_info.get('site_name', 'Unknown') + print(f" - Site Name: {site_name}") + battery_commission_date = site_info.get('battery_commission_date', 'Unknown') + print(f" - Battery Commission Date: {battery_commission_date}") + vin = config.get('vin', 'Unknown') + print(f" - VIN: {vin}") + number_of_powerwalls = len(config.get('battery_blocks', [])) + print(f" - Number of Powerwalls: {number_of_powerwalls}") + print() + + # Print power data + print(" - Power Data:") + nominalEnergyRemainingWh = status.get('control', {}).get('systemStatus', {}).get('nominalEnergyRemainingWh', 0) + nominalFullPackEnergyWh = status.get('control', {}).get('systemStatus', {}).get('nominalFullPackEnergyWh', 0) + soe = round(nominalEnergyRemainingWh / nominalFullPackEnergyWh * 100, 2) + print(f" - Battery Charge: {soe}% ({nominalEnergyRemainingWh}Wh of {nominalFullPackEnergyWh}Wh)") + meterAggregates = status.get('control', {}).get('meterAggregates', []) + for meter in meterAggregates: + location = meter.get('location', 'Unknown').title() + realPowerW = int(meter.get('realPowerW', 0)) + print(f" - {location}: {realPowerW}W") + print() + + # Save Configuration and Status to JSON files + with open('status.json', 'w') as f: + json.dump(status, f) + with open('config.json', 'w') as f: + json.dump(config, f) + print(" - Configuration and Status saved to config.json and status.json") + print() + +if __name__ == "__main__": + run_tedapi_test() diff --git a/requirements.txt b/requirements.txt index 79ead2f..d9387aa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ # requests -protobuf +protobuf>=3.20.0 teslapy \ No newline at end of file diff --git a/setup.py b/setup.py index c52e395..3fca6b7 100644 --- a/setup.py +++ b/setup.py @@ -17,7 +17,7 @@ packages=setuptools.find_packages(), install_requires=[ 'requests', - 'protobuf', + 'protobuf>=3.20.0', 'teslapy', ], classifiers=[