diff --git a/RELEASE.md b/RELEASE.md index 3fb8731..158ee58 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,5 +1,10 @@ # RELEASE NOTES +## v0.8.3 - Error Handling + +* Added additional error handling logic to clean up exceptions. +* Proxy: Added command APIs for setting backup reserve and operating mode. + ## v0.8.2 - 503 Error Handling * Added 5 minute cooldown for HTTP 503 Service Unavailable errors from API calls. diff --git a/proxy/README.md b/proxy/README.md index 64736a7..d8ebe23 100644 --- a/proxy/README.md +++ b/proxy/README.md @@ -4,9 +4,11 @@ This pyPowerwall Caching Proxy handles authentication to the Powerwall Gateway and will proxy API calls to /api/meters/aggregates (power metrics), /api/system_status/soe (battery level), and many others (see [HELP](https://github.com/jasonacox/pypowerwall/blob/main/proxy/HELP.md) for full list). With the instructions below, you can containerize this proxy and run it as an endpoint for tools like telegraf to pull metrics without needing to authenticate. -Because pyPowerwall is designed to cache the auth and high frequency API calls, while also utilising HTTP persistent connections, this will reduce the load on the Gateway and prevent crash/restart issues that can happen if too many session are created on the Gateway. +**Cache**: Because pyPowerwall is designed to cache the auth and high frequency API calls and use HTTP persistent connections. This will help reduce the load on the Gateway and prevent crash/restart issues that can happen if too many session are created on the Gateway. Logic in pypowerwall will also activate cooldown modes if the Gateway responds with errors indicating overload. -Docker: docker pull [jasonacox/pypowerwall](https://hub.docker.com/r/jasonacox/pypowerwall) +**Local or Cloud**: The proxy uses the built in abstraction of pypowerwall to operate in two modes: `local mode` and `cloud mode`. Local mode will connect directly with your Powerwall's Tesla Energy Gateway (TEG) to pull realtime data. Cloud mode will connect to the Tesla cloud APIs to pull realtime data. Cloud mode has lower fidelity than local mode and does not include some data points available on the the local API. + +**Control Mode**: An optional mode allows the proxy to send control commands to set backup reserve percentage and mode of the Powerwall. This requires that you set and use the `PW_CONTROL_SECRET` environmental variable. For safety reasons, this mode is disabled by default and should be used with caution. ## Quick Start @@ -14,21 +16,25 @@ Docker: docker pull [jasonacox/pypowerwall](https://hub.docker.com/r/jasonacox/p ```bash docker run \ - -d \ - -p 8675:8675 \ - -e PW_PORT='8675' \ - -e PW_PASSWORD='password' \ - -e PW_EMAIL='email@example.com' \ - -e PW_HOST='IP_of_Powerwall_Gateway' \ - -e PW_TIMEZONE='America/Los_Angeles' \ - -e TZ='America/Los_Angeles' \ - -e PW_CACHE_EXPIRE='5' \ - -e PW_DEBUG='no' \ - -e PW_HTTPS='no' \ - -e PW_STYLE='clear' \ - --name pypowerwall \ - --restart unless-stopped \ - jasonacox/pypowerwall + -d \ + -p 8675:8675 \ + -e PW_PORT='8675' \ + -e PW_PASSWORD='password' \ + -e PW_EMAIL='email@example.com' \ + -e PW_HOST='IP_of_Powerwall_Gateway' \ + -e PW_TIMEZONE='America/Los_Angeles' \ + -e TZ='America/Los_Angeles' \ + -e PW_CACHE_EXPIRE='5' \ + -e PW_DEBUG='no' \ + -e PW_HTTPS='no' \ + -e PW_STYLE='clear' \ + --name pypowerwall \ + --restart unless-stopped \ + jasonacox/pypowerwall + + # Cloud Mode Setup - Optional + docker exec -it pypowerwall python3 -m pypowerwall setup -email=email@example.com + docker restart pypowerwall ``` 2. Test the Proxy @@ -68,11 +74,11 @@ The `Dockerfile` here will allow you to containerize the proxy server for clean ```bash docker run \ - -d \ - -p 8675:8675 \ - --name pypowerwall \ - --restart unless-stopped \ - pypowerwall + -d \ + -p 8675:8675 \ + --name pypowerwall \ + --restart unless-stopped \ + pypowerwall ``` 3. Test the Proxy @@ -142,10 +148,10 @@ The pyPowerwall Proxy will react to the following environmental variables with ( Powerwall Settings -* PW_PASSWORD - Powerwall customer password ("password") -* PW_EMAIL - Powerwall customer email ("email@example.com") -* PW_HOST - Powerwall hostname or IP address ("hostname") -* PW_TIMEZONE - Local timezone ("America/Los_Angeles") +* PW_PASSWORD - Powerwall customer password ("password") [required] +* PW_EMAIL - Powerwall customer email ("email@example.com") [required for cloudmode] +* PW_HOST - Powerwall hostname or IP address ("hostname") [required for local mode] +* PW_TIMEZONE - Local timezone ("America/Los_Angeles") [optional] Proxy Settings @@ -163,6 +169,57 @@ Proxy Settings * white (uses `#ffffff` ![#ffffff](https://via.placeholder.com/12/ffffff/000000.png?text=+)) * grafana (uses `#161719` ![#161719](https://via.placeholder.com/12/161719/000000.png?text=+)) * grafana-dark (uses `#111217` ![#111217](https://via.placeholder.com/12/111217/000000.png?text=+)) +* PW_AUTH_PATH - Location (path) for authentication and cache files ("") +* PW_AUTH_MODE - Use `cookie` (default) or `token` for authentication +* PW_CACHE_FILE - Proxy cache file path, with override PW_AUTH_PATH if provided (".powerwall") +* PW_SITEID - For `cloud mode`, if you have multiple sites configured, use this site ID ("") +* PW_CONTROL_SECRET - If provided, will activate the Powerwall control commands to adjust Powerwall backup reserve level and mode (disabled by default) + +## Control Mode + +If the `PW_CONTROL_SECRET` environmental variable is set, the proxy will attempt to connect ot the cloud in addition to local mode setup (if you are using local mode). The `PW_EMAIL` must match your Tesla account and you need to run **cloud setup** before using this mode. + +_WARNING_: Activating control mode means that the proxy can make changes to your system. This will be available to anyone who can access the proxy. For safety reasons, this mode is disabled by default and should be used with caution. + +```bash +# Run Proxy +docker run \ + -d \ + -p 8675:8675 \ + -e PW_PORT='8675' \ + -e PW_PASSWORD='password' \ + -e PW_EMAIL='email@example.com' \ + -e PW_HOST='IP_of_Powerwall_Gateway' \ + -e PW_TIMEZONE='America/Los_Angeles' \ + -e TZ='America/Los_Angeles' \ + -e PW_CONTROL_SECRET='YourSecretToken' \ + --name pypowerwall \ + --restart unless-stopped \ + jasonacox/pypowerwall + +# Setup Cloud +docker exec -it pypowerwall python3 -m pypowerwall setup -email=email@example.com +docker restart pypowerwall +``` + +APIs + +* Set Mode: `/control/mode?token=$PW_CONTROL_SECRET&value=self_consumption` +* Set Reserve: `/control/reserve?token=$PW_CONTROL_SECRET&value=20` + +Examples + +```bash +# Set Mode +curl "http://localhost:8675/control/mode?token=$PW_CONTROL_SECRET&value=self_consumption" + +# Set Reserve +curl "http://localhost:8675/control/reserve?token=$PW_CONTROL_SECRET&value=20" + +# Omit Value to Read Settings +curl "http://localhost:8675/control/mode?token=$PW_CONTROL_SECRET" +curl "http://localhost:8675/control/reserve?token=$PW_CONTROL_SECRET" +``` ## Release Notes diff --git a/proxy/RELEASE.md b/proxy/RELEASE.md index 55e778f..e7eee49 100644 --- a/proxy/RELEASE.md +++ b/proxy/RELEASE.md @@ -1,5 +1,22 @@ ## pyPowerwall Proxy Release Notes +### Proxy t54 (13 Apr 2024) + +* Fix `/pod` API to add `time_remaining_hours` and `backup_reserve_percent` for cloud mode. +* Added GET command APIs to set backup reserve and operating mode settings. Requires setting `PW_CONTROL_SECRET`. Use with caution. + +```bash +# Set Mode +curl http://localhost:8675/control/mode?token=$PW_CONTROL_SECRET&value=self_consumption + +# Set Reserve +curl http://localhost:8675/control/reserve?token=$PW_CONTROL_SECRET&value=20 + +# Omit Value to Read Settings +curl http://localhost:8675/control/mode?token=$PW_CONTROL_SECRET +curl http://localhost:8675/control/reserve?token=$PW_CONTROL_SECRET +``` + ### Proxy t53 (11 Apr 2024) * Add DISABLED API handling logic. diff --git a/proxy/requirements.txt b/proxy/requirements.txt index 08df15d..1cdfa37 100644 --- a/proxy/requirements.txt +++ b/proxy/requirements.txt @@ -1,2 +1,2 @@ -pypowerwall==0.8.2 +pypowerwall==0.8.3 bs4==0.0.2 diff --git a/proxy/server.py b/proxy/server.py index 2de442c..4f8bdd4 100755 --- a/proxy/server.py +++ b/proxy/server.py @@ -28,6 +28,13 @@ /strings data. Set: PW_EMAIL and leave PW_HOST blank to use this mode. + Control Mode + An optional mode is to enable control commands to set backup reserve + percentage and mode of the Powerwall. This requires that you set + and use the PW_CONTROL_SECRET environmental variable. This mode + is disabled by default and should be used with caution. + Set: PW_CONTROL_SECRET to enable this mode. + """ import datetime import json @@ -45,8 +52,9 @@ import pypowerwall from pypowerwall import parse_version from transform import get_static, inject_js +from urllib.parse import urlparse, parse_qs -BUILD = "t53" +BUILD = "t54" ALLOWLIST = [ '/api/status', '/api/site_info/site_name', '/api/meters/site', '/api/meters/solar', '/api/sitemaster', '/api/powerwalls', @@ -84,6 +92,7 @@ if authpath: cf = os.path.join(authpath, ".powerwall") cachefile = os.getenv("PW_CACHE_FILE", cf) +control_secret = os.getenv("PW_CONTROL_SECRET", "") # Global Stats proxystats = { @@ -181,6 +190,20 @@ def get_value(a, key): log.info("pyPowerwall Proxy Server - Local Mode") log.info("Connected to Energy Gateway %s (%s)" % (host, pw.site_name().strip())) +if control_secret: + log.info("Control Commands Activating - WARNING: Use with caution!") + try: + if pw.cloudmode: + pw_control = pw + else: + pw_control = pypowerwall.Powerwall("", password, email, siteid=siteid, + authpath=authpath, authmode=authmode, + cachefile=cachefile) + except Exception as e: + log.error("Control Mode Failed: Unable to connect to cloud - Run Setup") + control_secret = "" + if pw_control: + log.info("Control Mode Enabled: Cloud Mode Connected") class ThreadingHTTPServer(ThreadingMixIn, HTTPServer): daemon_threads = True @@ -387,10 +410,10 @@ def do_GET(self): # Aggregate data if pod: # Only poll if we have battery data - pod["time_remaining_hours"] = pw.get_time_remaining() - pod["backup_reserve_percent"] = pw.get_reserve() 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) elif self.path == '/version': # Firmware Version @@ -444,6 +467,52 @@ def do_GET(self): elif self.path in ALLOWLIST: # Allowed API Calls - Proxy to Powerwall message: str = pw.poll(self.path, jsonformat=True) + elif self.path.startswith('/control/'): + # URL = /control/{action}?value={value}&token={token} + message = None + if not control_secret: + message = '{"error": "Control Commands Disabled - Set PW_CONTROL_SECRET to enable"}' + else: + try: + action = urlparse(self.path).path.split('/')[2] + query_params = parse_qs(urlparse(self.path).query) + value = query_params.get('value', [''])[0] + token = query_params.get('token', [''])[0] + except Exception as e: + message = '{"error": "Control Command Error: Invalid URL"}' + log.error(f"Control Command Error: {e}") + if not message: + # Check if unable to connect to cloud + if pw_control.client is None: + message = '{"error": "Control Command Error: Unable to connect to cloud mode - Run Setup"}' + log.error("Control Command Error: Unable to connect to cloud mode - Run Setup") + else: + if token == control_secret: + if action == 'reserve': + # ensure value is an integer + if not value: + # return current reserve level in json string + message = '{"reserve": %s}' % pw_control.get_reserve() + else: + if value.isdigit(): + message = json.dumps(pw_control.set_reserve(int(value))) + log.info(f"Control Command: Set Reserve to {value}") + else: + message = '{"error": "Control Command Value Invalid"}' + elif action == 'mode': + if not value: + # return current mode in json string + message = '{"mode": "%s"}' % pw_control.get_mode() + else: + if value in ['self_consumption', 'backup', 'autonomous']: + message = json.dumps(pw_control.set_mode(value)) + log.info(f"Control Command: Set Mode to {value}") + else: + message = '{"error": "Control Command Value Invalid"}' + else: + message = '{"error": "Control Command Not Found"}' + else: + message = '{"error": "Control Command Token Invalid"}' else: # Everything else - Set auth headers required for web application proxystats['gets'] = proxystats['gets'] + 1 diff --git a/pypowerwall/__init__.py b/pypowerwall/__init__.py index 1377481..26b159f 100644 --- a/pypowerwall/__init__.py +++ b/pypowerwall/__init__.py @@ -90,7 +90,7 @@ urllib3.disable_warnings() # Disable SSL warnings -version_tuple = (0, 8, 2) +version_tuple = (0, 8, 3) version = __version__ = '%d.%d.%d' % version_tuple __author__ = 'jasonacox' @@ -602,6 +602,10 @@ def grid_status(self, type="string") -> Optional[Union[str, int]]: payload: dict = self.poll('/api/system_status/grid_status') + if payload is None: + log.error(f"Failed to get /api/system_status/grid_status") + return None + if type == "json": return json.dumps(payload, indent=4, sort_keys=True) @@ -613,7 +617,7 @@ def grid_status(self, type="string") -> Optional[Union[str, int]]: 'SystemMicroGridFaulted': {'string': 'DOWN', 'numeric': 0}, 'SystemWaitForUser': {'string': 'DOWN', 'numeric': 0}} - grid_status = payload['grid_status'] + grid_status = payload.get('grid_status') status = gridmap.get(grid_status, {}).get(type) if status is None: log.debug(f"ERROR unable to parse payload '{payload}' for grid_status of type: {type}")