From 0fffceb7ef89cf85396651f3215295f498a55276 Mon Sep 17 00:00:00 2001 From: Christopher Pitstick Date: Tue, 7 Jan 2025 23:41:26 -0500 Subject: [PATCH 01/35] Beginning work on refactoring the proxy. --- proxy/server.py | 370 +++++++++++++++++++++++++++--------------------- 1 file changed, 211 insertions(+), 159 deletions(-) diff --git a/proxy/server.py b/proxy/server.py index afe1e00..c390267 100755 --- a/proxy/server.py +++ b/proxy/server.py @@ -45,60 +45,168 @@ import ssl import sys import time +from enum import StrEnum, auto from http.server import BaseHTTPRequestHandler, HTTPServer from socketserver import ThreadingMixIn -from typing import Optional -from urllib.parse import urlparse, parse_qs +from typing import Dict, Final, Optional, Set, Union +from urllib.parse import parse_qs, urlparse from transform import get_static, inject_js + import pypowerwall from pypowerwall import parse_version BUILD = "t67" -ALLOWLIST = [ - '/api/status', '/api/site_info/site_name', '/api/meters/site', - '/api/meters/solar', '/api/sitemaster', '/api/powerwalls', - '/api/customer/registration', '/api/system_status', '/api/system_status/grid_status', - '/api/system/update/status', '/api/site_info', '/api/system_status/grid_faults', - '/api/operation', '/api/site_info/grid_codes', '/api/solars', '/api/solars/brands', - '/api/customer', '/api/meters', '/api/installer', '/api/networks', - '/api/system/networks', '/api/meters/readings', '/api/synchrometer/ct_voltage_references', - '/api/troubleshooting/problems', '/api/auth/toggle/supported', '/api/solar_powerwall', -] -DISABLED = [ + +ALLOWLIST = Set[str] = set([ + '/api/status', + '/api/site_info/site_name', + '/api/meters/site', + '/api/meters/solar', + '/api/sitemaster', + '/api/powerwalls', + '/api/customer/registration', + '/api/system_status', + '/api/system_status/grid_status', + '/api/system/update/status', + '/api/site_info', + '/api/system_status/grid_faults', + '/api/operation', + '/api/site_info/grid_codes', + '/api/solars', + '/api/solars/brands', + '/api/customer', + '/api/meters', + '/api/installer', + '/api/networks', + '/api/system/networks', + '/api/meters/readings', + '/api/synchrometer/ct_voltage_references', + '/api/troubleshooting/problems', + '/api/auth/toggle/supported', + '/api/solar_powerwall', +]) + +DISABLED = Set[str] = set([ '/api/customer/registration', -] -web_root = os.path.join(os.path.dirname(__file__), "web") +]) +WEB_ROOT: Final[str] = os.path.join(os.path.dirname(__file__), "web") + + + + # bind_address = os.getenv("PW_BIND_ADDRESS", "") + # password = os.getenv("PW_PASSWORD", "") + # email = os.getenv("PW_EMAIL", "email@example.com") + # host = os.getenv("PW_HOST", "") + # timezone = os.getenv("PW_TIMEZONE", "America/Los_Angeles") + # debugmode = os.getenv("PW_DEBUG", "no").lower() == "yes" + # cache_expire = int(os.getenv("PW_CACHE_EXPIRE", "5")) + # browser_cache = int(os.getenv("PW_BROWSER_CACHE", "0")) + # timeout = int(os.getenv("PW_TIMEOUT", "5")) + # pool_maxsize = int(os.getenv("PW_POOL_MAXSIZE", "15")) + # https_mode = os.getenv("PW_HTTPS", "no") + # port = int(os.getenv("PW_PORT", "8675")) + # style = os.getenv("PW_STYLE", "clear") + ".js" + # 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) + # control_secret = os.getenv("PW_CONTROL_SECRET", "") + # gw_pwd = os.getenv("PW_GW_PWD", None) + # neg_solar = os.getenv("PW_NEG_SOLAR", "yes").lower() == "yes" + +class CONFIG_TYPE(StrEnum): + """_summary_ + + Args: + StrEnum (_type_): _description_ + """ + PW_BIND_ADDRESS = auto() + PW_PASSWORD = auto() + PW_EMAIL = auto() + PW_HOST = auto() + PW_TIMEZONE = auto() + PW_DEBUG = auto() + PW_CACHE_EXPIRE = auto() + PW_BROWSER_CACHE = auto() + PW_TIMEOUT = auto() + PW_POOL_MAXSIZE = auto() + PW_HTTPS = auto() + PW_HTTP_TYPE = auto() + PW_PORT = auto() + PW_STYLE = auto() + PW_SITEID = auto() + PW_AUTH_PATH = auto() + PW_AUTH_MODE = auto() + PW_CONTROL_SECRET = auto() + PW_GW_PWD = auto() + PW_NEG_SOLAR = auto() + PW_CACHE_FILE = auto() + PW_AUTH_PATH = auto() # Configuration for Proxy - Check for environmental variables # and always use those if available (required for Docker) -bind_address = os.getenv("PW_BIND_ADDRESS", "") -password = os.getenv("PW_PASSWORD", "") -email = os.getenv("PW_EMAIL", "email@example.com") -host = os.getenv("PW_HOST", "") -timezone = os.getenv("PW_TIMEZONE", "America/Los_Angeles") -debugmode = os.getenv("PW_DEBUG", "no").lower() == "yes" -cache_expire = int(os.getenv("PW_CACHE_EXPIRE", "5")) -browser_cache = int(os.getenv("PW_BROWSER_CACHE", "0")) -timeout = int(os.getenv("PW_TIMEOUT", "5")) -pool_maxsize = int(os.getenv("PW_POOL_MAXSIZE", "15")) -https_mode = os.getenv("PW_HTTPS", "no") -port = int(os.getenv("PW_PORT", "8675")) -style = os.getenv("PW_STYLE", "clear") + ".js" -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) -control_secret = os.getenv("PW_CONTROL_SECRET", "") -gw_pwd = os.getenv("PW_GW_PWD", None) -neg_solar = os.getenv("PW_NEG_SOLAR", "yes").lower() == "yes" +# Configuration - Environment variables +CONFIG: Dict[CONFIG_TYPE, str | int | bool | None] = { + CONFIG_TYPE.PW_BIND_ADDRESS: os.getenv(CONFIG_TYPE.PW_BIND_ADDRESS, ""), + CONFIG_TYPE.PW_PASSWORD: os.getenv(CONFIG_TYPE.PW_PASSWORD, ""), + CONFIG_TYPE.PW_HOST: os.getenv(CONFIG_TYPE.PW_HOST, ""), + CONFIG_TYPE.PW_CONTROL_SECRET: os.getenv(CONFIG_TYPE.PW_CONTROL_SECRET, ""), + CONFIG_TYPE.PW_EMAIL: os.getenv(CONFIG_TYPE.PW_EMAIL, "email@example.com"), + CONFIG_TYPE.PW_TIMEZONE: os.getenv(CONFIG_TYPE.PW_TIMEZONE, "America/Los_Angeles"), + CONFIG_TYPE.PW_CACHE_EXPIRE: int(os.getenv(CONFIG_TYPE.PW_CACHE_EXPIRE, "5")), + CONFIG_TYPE.PW_BROWSER_CACHE: int(os.getenv(CONFIG_TYPE.PW_BROWSER_CACHE, "0")), + CONFIG_TYPE.PW_TIMEOUT: int(os.getenv(CONFIG_TYPE.PW_TIMEOUT, "5")), + CONFIG_TYPE.PW_POOL_MAXSIZE: int(os.getenv(CONFIG_TYPE.PW_POOL_MAXSIZE, "15")), + CONFIG_TYPE.PW_HTTPS: os.getenv(CONFIG_TYPE.PW_HTTPS, "no"), + CONFIG_TYPE.PW_PORT: int(os.getenv(CONFIG_TYPE.PW_PORT, "8675")), + CONFIG_TYPE.PW_STYLE: os.getenv(CONFIG_TYPE.PW_STYLE, "clear") + ".js", + CONFIG_TYPE.PW_SITEID: os.getenv(CONFIG_TYPE.PW_SITEID, None), + CONFIG_TYPE.PW_AUTH_PATH: os.getenv(CONFIG_TYPE.PW_AUTH_PATH, ""), + CONFIG_TYPE.PW_AUTH_MODE: os.getenv(CONFIG_TYPE.PW_AUTH_MODE, "cookie"), + CONFIG_TYPE.PW_GW_PWD: os.getenv(CONFIG_TYPE.PW_GW_PWD, None), + CONFIG_TYPE.PW_DEBUG: bool(os.getenv(CONFIG_TYPE.PW_DEBUG, "no").lower() == "yes"), + CONFIG_TYPE.PW_NEG_SOLAR: bool(os.getenv(CONFIG_TYPE.PW_NEG_SOLAR, "yes").lower() == "yes") +} + +# Cache file +CONFIG[CONFIG_TYPE.PW_CACHE_FILE] = os.getenv( + CONFIG_TYPE.PW_CACHE_FILE, + os.path.join(CONFIG[CONFIG_TYPE.PW_AUTH_PATH], ".powerwall") if CONFIG[CONFIG_TYPE.PW_AUTH_PATH] else ".powerwall" +) + +# HTTP/S configuration +if CONFIG[CONFIG_TYPE.PW_HTTPS].lower() == "yes": + COOKIE_SUFFIX = "path=/;SameSite=None;Secure;" + CONFIG[CONFIG_TYPE.PW_HTTP_TYPE] = "HTTPS" +elif CONFIG[CONFIG_TYPE.PW_HTTPS].lower() == "http": + COOKIE_SUFFIX = "path=/;SameSite=None;Secure;" + CONFIG[CONFIG_TYPE.PW_HTTP_TYPE] = "HTTP" +else: + COOKIE_SUFFIX = "path=/;" + CONFIG[CONFIG_TYPE.PW_HTTP_TYPE] = "HTTP" + +# Logging configuration +log = logging.getLogger("proxy") +logging.basicConfig(format='%(levelname)s:%(message)s', level=logging.INFO) +log.setLevel(logging.DEBUG if CONFIG[CONFIG_TYPE.PW_DEBUG] else logging.INFO) + +if CONFIG[CONFIG_TYPE.PW_DEBUG]: + pypowerwall.set_debug(True) + +# Signal handler - Exit on SIGTERM +# noinspection PyUnusedLocal +def sig_term_handle(signum, frame): + raise SystemExit + +signal.signal(signal.SIGTERM, sig_term_handle) # Global Stats proxystats = { - 'pypowerwall': "%s Proxy %s" % (pypowerwall.version, BUILD), + 'pypowerwall': f"{pypowerwall.version} Proxy {BUILD}", 'mode': "Unknown", 'gets': 0, 'posts': 0, @@ -118,78 +226,25 @@ 'tedapi_mode': "off", 'siteid': None, 'counter': 0, - 'cf': cachefile, - 'config': { - 'PW_BIND_ADDRESS': bind_address, - 'PW_PASSWORD': '*' * len(password) if password else None, - 'PW_EMAIL': email, - 'PW_HOST': host, - 'PW_TIMEZONE': timezone, - 'PW_DEBUG': debugmode, - 'PW_CACHE_EXPIRE': cache_expire, - 'PW_BROWSER_CACHE': browser_cache, - 'PW_TIMEOUT': timeout, - 'PW_POOL_MAXSIZE': pool_maxsize, - 'PW_HTTPS': https_mode, - 'PW_PORT': port, - 'PW_STYLE': style, - 'PW_SITEID': siteid, - 'PW_AUTH_PATH': authpath, - 'PW_AUTH_MODE': authmode, - 'PW_CACHE_FILE': cachefile, - 'PW_CONTROL_SECRET': '*' * len(control_secret) if control_secret else None, - 'PW_GW_PWD': '*' * len(gw_pwd) if gw_pwd else None, - 'PW_NEG_SOLAR': neg_solar - } + 'cf': CONFIG[CONFIG_TYPE.PW_CACHE_FILE], + 'config': CONFIG.copy() } -if https_mode == "yes": - # run https mode with self-signed cert - cookiesuffix = "path=/;SameSite=None;Secure;" - httptype = "HTTPS" -elif https_mode == "http": - # run http mode but simulate https for proxy behind https proxy - cookiesuffix = "path=/;SameSite=None;Secure;" - httptype = "HTTP" -else: - # run in http mode - cookiesuffix = "path=/;" - httptype = "HTTP" - -# Logging -log = logging.getLogger("proxy") -logging.basicConfig(format='%(levelname)s:%(message)s', level=logging.INFO) -log.setLevel(logging.INFO) - -if debugmode: - log.info("pyPowerwall [%s] Proxy Server [%s] - %s Port %d - DEBUG" % - (pypowerwall.version, BUILD, httptype, port)) - pypowerwall.set_debug(True) - log.setLevel(logging.DEBUG) -else: - log.info("pyPowerwall [%s] Proxy Server [%s] - %s Port %d" % - (pypowerwall.version, BUILD, httptype, port)) +log.info( + f"pyPowerwall [{pypowerwall.version}] Proxy Server [{BUILD}] - {CONFIG[CONFIG_TYPE.PW_HTTP_TYPE]} Port {CONFIG['PW_PORT']}{' - DEBUG' if CONFIG[CONFIG_TYPE.PW_DEBUG] else ''}" +) log.info("pyPowerwall Proxy Started") # Check for cache expire time limit below 5s -if cache_expire < 5: - log.warning("Cache expiration set below 5s (PW_CACHE_EXPIRE=%d)" % cache_expire) - -# Signal handler - Exit on SIGTERM -# noinspection PyUnusedLocal -def sig_term_handle(signum, frame): - raise SystemExit - -# Register signal handler -signal.signal(signal.SIGTERM, sig_term_handle) - +if CONFIG['PW_CACHE_EXPIRE'] < 5: + log.warning(f"Cache expiration set below 5s (PW_CACHE_EXPIRE={CONFIG['PW_CACHE_EXPIRE']})") # Get Value Function - Key to Value or Return Null def get_value(a, key): if key in a: return a[key] else: - log.debug("Missing key in payload [%s]" % key) + log.debug(f"Missing key in payload [{key}]") return None @@ -481,64 +536,61 @@ def do_GET(self): # Get Individual Powerwall Battery Data d = pw.system_status() or {} if "battery_blocks" in d: - idx = 1 - for block in d["battery_blocks"]: + for idx, block in enumerate(d["battery_blocks"], start=1): # Vital Placeholders - pod["PW%d_name" % idx] = None - pod["PW%d_POD_ActiveHeating" % idx] = None - pod["PW%d_POD_ChargeComplete" % idx] = None - pod["PW%d_POD_ChargeRequest" % idx] = None - pod["PW%d_POD_DischargeComplete" % idx] = None - pod["PW%d_POD_PermanentlyFaulted" % idx] = None - pod["PW%d_POD_PersistentlyFaulted" % idx] = None - pod["PW%d_POD_enable_line" % idx] = None - pod["PW%d_POD_available_charge_power" % idx] = None - pod["PW%d_POD_available_dischg_power" % idx] = None - pod["PW%d_POD_nom_energy_remaining" % idx] = None - pod["PW%d_POD_nom_energy_to_be_charged" % idx] = None - pod["PW%d_POD_nom_full_pack_energy" % idx] = None + pod[f"PW{idx}_name" % idx] = None + pod[f"PW{idx}_POD_ActiveHeating"] = None + pod[f"PW{idx}_POD_ChargeComplete"] = None + pod[f"PW{idx}_POD_ChargeRequest"] = None + pod[f"PW{idx}_POD_DischargeComplete"] = None + pod[f"PW{idx}_POD_PermanentlyFaulted"] = None + pod[f"PW{idx}_POD_PersistentlyFaulted"] = None + pod[f"PW{idx}_POD_enable_line"] = None + pod[f"PW{idx}_POD_available_charge_power"] = None + pod[f"PW{idx}_POD_available_dischg_power"] = None + pod[f"PW{idx}_POD_nom_energy_remaining"] = None + pod[f"PW{idx}_POD_nom_energy_to_be_charged"] = None + pod[f"PW{idx}_POD_nom_full_pack_energy"] = None # Additional System Status Data - pod["PW%d_POD_nom_energy_remaining" % idx] = get_value(block, "nominal_energy_remaining") # map - pod["PW%d_POD_nom_full_pack_energy" % idx] = get_value(block, "nominal_full_pack_energy") # map - pod["PW%d_PackagePartNumber" % idx] = get_value(block, "PackagePartNumber") - pod["PW%d_PackageSerialNumber" % idx] = get_value(block, "PackageSerialNumber") - pod["PW%d_pinv_state" % idx] = get_value(block, "pinv_state") - pod["PW%d_pinv_grid_state" % idx] = get_value(block, "pinv_grid_state") - pod["PW%d_p_out" % idx] = get_value(block, "p_out") - pod["PW%d_q_out" % idx] = get_value(block, "q_out") - pod["PW%d_v_out" % idx] = get_value(block, "v_out") - pod["PW%d_f_out" % idx] = get_value(block, "f_out") - pod["PW%d_i_out" % idx] = get_value(block, "i_out") - pod["PW%d_energy_charged" % idx] = get_value(block, "energy_charged") - pod["PW%d_energy_discharged" % idx] = get_value(block, "energy_discharged") - pod["PW%d_off_grid" % idx] = int(get_value(block, "off_grid") or 0) - pod["PW%d_vf_mode" % idx] = int(get_value(block, "vf_mode") or 0) - pod["PW%d_wobble_detected" % idx] = int(get_value(block, "wobble_detected") or 0) - pod["PW%d_charge_power_clamped" % idx] = int(get_value(block, "charge_power_clamped") or 0) - pod["PW%d_backup_ready" % idx] = int(get_value(block, "backup_ready") or 0) - pod["PW%d_OpSeqState" % idx] = get_value(block, "OpSeqState") - pod["PW%d_version" % idx] = get_value(block, "version") - idx = idx + 1 + pod[f"PW{idx}_POD_nom_energy_remaining"] = get_value(block, "nominal_energy_remaining") # map + pod[f"PW{idx}_POD_nom_full_pack_energy"] = get_value(block, "nominal_full_pack_energy") # map + pod[f"PW{idx}_PackagePartNumber"] = get_value(block, "PackagePartNumber") + pod[f"PW{idx}_PackageSerialNumber"] = get_value(block, "PackageSerialNumber") + pod[f"PW{idx}_pinv_state"] = get_value(block, "pinv_state") + pod[f"PW{idx}_pinv_grid_state"] = get_value(block, "pinv_grid_state") + pod[f"PW{idx}_p_out"] = get_value(block, "p_out") + pod[f"PW{idx}_q_out"] = get_value(block, "q_out") + pod[f"PW{idx}_v_out"] = get_value(block, "v_out") + pod[f"PW{idx}_f_out"] = get_value(block, "f_out") + pod[f"PW{idx}_i_out"] = get_value(block, "i_out") + pod[f"PW{idx}_energy_charged"] = get_value(block, "energy_charged") + pod[f"PW{idx}_energy_discharged"] = get_value(block, "energy_discharged") + pod[f"PW{idx}_off_grid"] = int(get_value(block, "off_grid") or 0) + pod[f"PW{idx}_vf_mode"] = int(get_value(block, "vf_mode") or 0) + pod[f"PW{idx}_wobble_detected"] = int(get_value(block, "wobble_detected") or 0) + pod[f"PW{idx}_charge_power_clamped"] = int(get_value(block, "charge_power_clamped") or 0) + pod[f"PW{idx}_backup_ready"] = int(get_value(block, "backup_ready") or 0) + pod[f"PW{idx}_OpSeqState"] = get_value(block, "OpSeqState") + pod[f"PW{idx}_version"] = get_value(block, "version") # Augment with Vitals Data if available vitals = pw.vitals() or {} - idx = 1 - for device in vitals: - v = vitals[device] - if device.startswith('TEPOD'): - pod["PW%d_name" % idx] = device - pod["PW%d_POD_ActiveHeating" % idx] = int(get_value(v, 'POD_ActiveHeating') or 0) - pod["PW%d_POD_ChargeComplete" % idx] = int(get_value(v, 'POD_ChargeComplete') or 0) - pod["PW%d_POD_ChargeRequest" % idx] = int(get_value(v, 'POD_ChargeRequest') or 0) - pod["PW%d_POD_DischargeComplete" % idx] = int(get_value(v, 'POD_DischargeComplete') or 0) - pod["PW%d_POD_PermanentlyFaulted" % idx] = int(get_value(v, 'POD_PermanentlyFaulted') or 0) - pod["PW%d_POD_PersistentlyFaulted" % idx] = int(get_value(v, 'POD_PersistentlyFaulted') or 0) - pod["PW%d_POD_enable_line" % idx] = int(get_value(v, 'POD_enable_line') or 0) - pod["PW%d_POD_available_charge_power" % idx] = get_value(v, 'POD_available_charge_power') - pod["PW%d_POD_available_dischg_power" % idx] = get_value(v, 'POD_available_dischg_power') - pod["PW%d_POD_nom_energy_remaining" % idx] = get_value(v, 'POD_nom_energy_remaining') - pod["PW%d_POD_nom_energy_to_be_charged" % idx] = get_value(v, 'POD_nom_energy_to_be_charged') - pod["PW%d_POD_nom_full_pack_energy" % idx] = get_value(v, 'POD_nom_full_pack_energy') - idx = idx + 1 + for idx, device in enumerate(vitals, start=1): + if not device.startswith('TEPOD'): + continue + v = vitals[device] + pod[f"PW{idx}_name"] = device + pod[f"PW{idx}_POD_ActiveHeating"] = int(get_value(v, 'POD_ActiveHeating') or 0) + pod[f"PW{idx}_POD_ChargeComplete"] = int(get_value(v, 'POD_ChargeComplete') or 0) + pod[f"PW{idx}_POD_ChargeRequest"] = int(get_value(v, 'POD_ChargeRequest') or 0) + pod[f"PW{idx}_POD_DischargeComplete"] = int(get_value(v, 'POD_DischargeComplete') or 0) + pod[f"PW{idx}_POD_PermanentlyFaulted"] = int(get_value(v, 'POD_PermanentlyFaulted') or 0) + pod[f"PW{idx}_POD_PersistentlyFaulted"] = int(get_value(v, 'POD_PersistentlyFaulted') or 0) + pod[f"PW{idx}_POD_enable_line"] = int(get_value(v, 'POD_enable_line') or 0) + pod[f"PW{idx}_POD_available_charge_power"] = get_value(v, 'POD_available_charge_power') + pod[f"PW{idx}_POD_available_dischg_power"] = get_value(v, 'POD_available_dischg_power') + pod[f"PW{idx}_POD_nom_energy_remaining"] = get_value(v, 'POD_nom_energy_remaining') + pod[f"PW{idx}_POD_nom_energy_to_be_charged"] = get_value(v, 'POD_nom_energy_to_be_charged') + pod[f"PW{idx}_POD_nom_full_pack_energy"] = get_value(v, 'POD_nom_full_pack_energy') # Aggregate data pod["nominal_full_pack_energy"] = get_value(d, 'nominal_full_pack_energy') pod["nominal_energy_remaining"] = get_value(d, 'nominal_energy_remaining') @@ -690,7 +742,7 @@ def do_GET(self): # pylint: disable=attribute-defined-outside-init if self.path == "/" or self.path == "": self.path = "/index.html" - fcontent, ftype = get_static(web_root, self.path) + fcontent, ftype = get_static(WEB_ROOT, self.path) # Replace {VARS} with current data status = pw.status() # convert fcontent to string @@ -703,7 +755,7 @@ def do_GET(self): # convert fcontent back to bytes fcontent = bytes(fcontent, 'utf-8') else: - fcontent, ftype = get_static(web_root, self.path) + fcontent, ftype = get_static(WEB_ROOT, self.path) if fcontent: log.debug("Served from local web root: {} type {}".format(self.path, ftype)) # If not found, serve from Powerwall web server @@ -716,8 +768,8 @@ def do_GET(self): proxy_path = self.path if proxy_path.startswith("/"): proxy_path = proxy_path[1:] - pw_url = "https://{}/{}".format(pw.host, proxy_path) - log.debug("Proxy request to: {}".format(pw_url)) + pw_url = f"https://{pw.host}/{proxy_path}" + log.debug(f"Proxy request to: {pw_url}") try: if pw.authmode == "token": r = pw.client.session.get( @@ -751,7 +803,7 @@ def do_GET(self): # Inject transformations if self.path.split('?')[0] == "/": - if os.path.exists(os.path.join(web_root, style)): + if os.path.exists(os.path.join(WEB_ROOT, style)): fcontent = bytes(inject_js(fcontent, style), 'utf-8') self.send_header('Content-type', '{}'.format(ftype)) From f5270315ad5c12e6f94e850d075a88afc3e8ef59 Mon Sep 17 00:00:00 2001 From: Christopher Pitstick Date: Wed, 8 Jan 2025 22:05:16 -0500 Subject: [PATCH 02/35] WIP --- proxy/server.py | 134 +++++++++++++++++++++++++----------------------- 1 file changed, 71 insertions(+), 63 deletions(-) diff --git a/proxy/server.py b/proxy/server.py index c390267..e39643f 100755 --- a/proxy/server.py +++ b/proxy/server.py @@ -48,8 +48,10 @@ from enum import StrEnum, auto from http.server import BaseHTTPRequestHandler, HTTPServer from socketserver import ThreadingMixIn -from typing import Dict, Final, Optional, Set, Union +from typing import Dict, Final, Optional, Set from urllib.parse import parse_qs, urlparse +from http import HTTPStatus + from transform import get_static, inject_js @@ -146,6 +148,7 @@ class CONFIG_TYPE(StrEnum): PW_NEG_SOLAR = auto() PW_CACHE_FILE = auto() PW_AUTH_PATH = auto() + PW_COOKIE_SUFFIX = auto() # Configuration for Proxy - Check for environmental variables # and always use those if available (required for Docker) @@ -180,13 +183,13 @@ class CONFIG_TYPE(StrEnum): # HTTP/S configuration if CONFIG[CONFIG_TYPE.PW_HTTPS].lower() == "yes": - COOKIE_SUFFIX = "path=/;SameSite=None;Secure;" + CONFIG[CONFIG_TYPE.PW_COOKIE_SUFFIX] = "path=/;SameSite=None;Secure;" CONFIG[CONFIG_TYPE.PW_HTTP_TYPE] = "HTTPS" elif CONFIG[CONFIG_TYPE.PW_HTTPS].lower() == "http": - COOKIE_SUFFIX = "path=/;SameSite=None;Secure;" + CONFIG[CONFIG_TYPE.PW_COOKIE_SUFFIX] = "path=/;SameSite=None;Secure;" CONFIG[CONFIG_TYPE.PW_HTTP_TYPE] = "HTTP" else: - COOKIE_SUFFIX = "path=/;" + CONFIG[CONFIG_TYPE.PW_COOKIE_SUFFIX] = "path=/;" CONFIG[CONFIG_TYPE.PW_HTTP_TYPE] = "HTTP" # Logging configuration @@ -241,21 +244,30 @@ def sig_term_handle(signum, frame): # Get Value Function - Key to Value or Return Null def get_value(a, key): - if key in a: - return a[key] - else: + value = a.get(key) + if value is None: log.debug(f"Missing key in payload [{key}]") - return None - + return value # Connect to Powerwall # TODO: Add support for multiple Powerwalls try: - pw = pypowerwall.Powerwall(host, password, email, timezone, cache_expire, - timeout, pool_maxsize, siteid=siteid, - authpath=authpath, authmode=authmode, - cachefile=cachefile, auto_select=True, - retry_modes=True, gw_pwd=gw_pwd) + pw = pypowerwall.Powerwall( + host=CONFIG[CONFIG_TYPE.PW_HOST], + password=CONFIG[CONFIG_TYPE.PW_PASSWORD], + email=CONFIG[CONFIG_TYPE.PW_EMAIL], + timezone=CONFIG[CONFIG_TYPE.PW_TIMEZONE], + cache_expire=CONFIG[CONFIG_TYPE.PW_CACHE_EXPIRE], + timeout=CONFIG[CONFIG_TYPE.PW_TIMEOUT], + pool_maxsize=CONFIG[CONFIG_TYPE.PW_POOL_MAXSIZE], + siteid=CONFIG[CONFIG_TYPE.PW_SITEID], + authpath=CONFIG[CONFIG_TYPE.PW_AUTH_PATH], + authmode=CONFIG[CONFIG_TYPE.PW_AUTH_MODE], + cachefile=CONFIG[CONFIG_TYPE.PW_CACHE_FILE], + auto_select=True, + retry_modes=True, + gw_pwd=CONFIG[CONFIG_TYPE.PW_GW_PWD] + ) except Exception as e: log.error(e) log.error("Fatal Error: Unable to connect. Please fix config and restart.") @@ -273,10 +285,10 @@ def get_value(a, key): else: proxystats['mode'] = "Cloud" log.info("pyPowerwall Proxy Server - Cloud Mode") - log.info("Connected to Site ID %s (%s)" % (pw.client.siteid, site_name.strip())) - if siteid is not None and siteid != str(pw.client.siteid): - log.info("Switch to Site ID %s" % siteid) - if not pw.client.change_site(siteid): + log.info(f"Connected to Site ID {pw.client.siteid} ({site_name.strip()})") + if CONFIG[CONFIG_TYPE.PW_SITEID] is not None and CONFIG[CONFIG_TYPE.PW_SITEID] != str(pw.client.siteid): + log.info("Switch to Site ID %s" % CONFIG[CONFIG_TYPE.PW_SITEID]) + if not pw.client.change_site(CONFIG[CONFIG_TYPE.PW_SITEID]): log.error("Fatal Error: Unable to connect. Please fix config and restart.") while True: try: @@ -286,7 +298,7 @@ def get_value(a, key): else: proxystats['mode'] = "Local" log.info("pyPowerwall Proxy Server - Local Mode") - log.info("Connected to Energy Gateway %s (%s)" % (host, site_name.strip())) + log.info(f"Connected to Energy Gateway {CONFIG[CONFIG_TYPE.PW_HOST]} ({site_name.strip()})") if pw.tedapi: proxystats['tedapi'] = True proxystats['tedapi_mode'] = pw.tedapi_mode @@ -294,7 +306,7 @@ def get_value(a, key): log.info(f"TEDAPI Mode Enabled for Device Vitals ({pw.tedapi_mode})") pw_control = None -if control_secret: +if CONFIG[CONFIG_TYPE.PW_CONTROL_SECRET]: log.info("Control Commands Activating - WARNING: Use with caution!") try: if pw.cloudmode or pw.fleetapi: @@ -305,12 +317,12 @@ def get_value(a, key): cachefile=cachefile, auto_select=True) except Exception as e: log.error("Control Mode Failed: Unable to connect to cloud - Run Setup") - control_secret = "" + CONFIG[CONFIG_TYPE.PW_CONTROL_SECRET] = "" if pw_control: log.info(f"Control Mode Enabled: Cloud Mode ({pw_control.mode}) Connected") else: log.error("Control Mode Failed: Unable to connect to cloud - Run Setup") - control_secret = None + CONFIG[CONFIG_TYPE.PW_CONTROL_SECRET] = None class ThreadingHTTPServer(ThreadingMixIn, HTTPServer): daemon_threads = True @@ -320,7 +332,7 @@ class ThreadingHTTPServer(ThreadingMixIn, HTTPServer): # noinspection PyPep8Naming class Handler(BaseHTTPRequestHandler): def log_message(self, log_format, *args): - if debugmode: + if CONFIG[CONFIG_TYPE.PW_DEBUG]: log.debug("%s %s" % (self.address_string(), log_format % args)) else: pass @@ -338,7 +350,7 @@ def do_POST(self): # curl -X POST -d "value=20&token=1234" http://localhost:8675/control/reserve # curl -X POST -d "value=backup&token=1234" http://localhost:8675/control/mode message = None - if not control_secret: + if not CONFIG[CONFIG_TYPE.PW_CONTROL_SECRET]: message = '{"error": "Control Commands Disabled - Set PW_CONTROL_SECRET to enable"}' else: try: @@ -356,7 +368,7 @@ def do_POST(self): 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 token == CONFIG[CONFIG_TYPE.PW_CONTROL_SECRET]: if action == 'reserve': # ensure value is an integer if not value: @@ -381,12 +393,12 @@ def do_POST(self): else: message = '{"unauthorized": "Control Command Token Invalid"}' if "error" in message: - self.send_response(400) + self.send_response(HTTPStatus.BAD_REQUEST) proxystats['errors'] = proxystats['errors'] + 1 elif "unauthorized" in message: - self.send_response(401) + self.send_response(HTTPStatus.UNAUTHORIZED) else: - self.send_response(200) + self.send_response(HTTPStatus.OK) proxystats['posts'] = proxystats['posts'] + 1 self.send_header('Content-type', contenttype) self.send_header('Content-Length', str(len(message))) @@ -402,7 +414,7 @@ def do_GET(self): if self.path == '/aggregates' or self.path == '/api/meters/aggregates': # Meters - JSON aggregates = pw.poll('/api/meters/aggregates') - if not neg_solar and aggregates and 'solar' in aggregates: + if not CONFIG[CONFIG_TYPE.PW_NEG_SOLAR] and aggregates and 'solar' in aggregates: solar = aggregates['solar'] if solar and 'instant_power' in solar and solar['instant_power'] < 0: solar['instant_power'] = 0 @@ -432,7 +444,7 @@ def do_GET(self): solar = pw.solar() or 0 battery = pw.battery() or 0 home = pw.home() or 0 - if not neg_solar and solar < 0: + if not CONFIG[CONFIG_TYPE.PW_NEG_SOLAR] and solar < 0: solar = 0 # Shift energy from solar to load home -= solar @@ -494,35 +506,31 @@ def do_GET(self): elif self.path == '/freq': # Frequency, Current, Voltage and Grid Status fcv = {} - idx = 1 # Pull freq, current, voltage of each Powerwall via system_status d = pw.system_status() or {} if "battery_blocks" in d: - for block in d["battery_blocks"]: - fcv["PW%d_name" % idx] = None # Placeholder for vitals - fcv["PW%d_PINV_Fout" % idx] = get_value(block, "f_out") - fcv["PW%d_PINV_VSplit1" % idx] = None # Placeholder for vitals - fcv["PW%d_PINV_VSplit2" % idx] = None # Placeholder for vitals - fcv["PW%d_PackagePartNumber" % idx] = get_value(block, "PackagePartNumber") - fcv["PW%d_PackageSerialNumber" % idx] = get_value(block, "PackageSerialNumber") - fcv["PW%d_p_out" % idx] = get_value(block, "p_out") - fcv["PW%d_q_out" % idx] = get_value(block, "q_out") - fcv["PW%d_v_out" % idx] = get_value(block, "v_out") - fcv["PW%d_f_out" % idx] = get_value(block, "f_out") - fcv["PW%d_i_out" % idx] = get_value(block, "i_out") - idx = idx + 1 + for idx, block in enumerate(d["battery_blocks"], start=1): + fcv[f"PW{idx}_name"] = None # Placeholder for vitals + fcv[f"PW{idx}_PINV_Fout"] = get_value(block, "f_out") + fcv[f"PW{idx}_PINV_VSplit1"] = None # Placeholder for vitals + fcv[f"PW{idx}_PINV_VSplit2"] = None # Placeholder for vitals + fcv[f"PW{idx}_PackagePartNumber"] = get_value(block, "PackagePartNumber") + fcv[f"PW{idx}_PackageSerialNumber"] = get_value(block, "PackageSerialNumber") + fcv[f"PW{idx}_p_out"] = get_value(block, "p_out") + fcv[f"PW{idx}_q_out"] = get_value(block, "q_out") + fcv[f"PW{idx}_v_out"] = get_value(block, "v_out") + fcv[f"PW{idx}_f_out"] = get_value(block, "f_out") + fcv[f"PW{idx}_i_out"] = get_value(block, "i_out") # Pull freq, current, voltage of each Powerwall via vitals if available vitals = pw.vitals() or {} - idx = 1 - for device in vitals: + for idx, device in enumerate(vitals, start=1): d = vitals[device] if device.startswith('TEPINV'): # PW freq - fcv["PW%d_name" % idx] = device - fcv["PW%d_PINV_Fout" % idx] = get_value(d, 'PINV_Fout') - fcv["PW%d_PINV_VSplit1" % idx] = get_value(d, 'PINV_VSplit1') - fcv["PW%d_PINV_VSplit2" % idx] = get_value(d, 'PINV_VSplit2') - idx = idx + 1 + fcv[f"PW{idx}_name"] = device + fcv[f"PW{idx}_PINV_Fout"] = get_value(d, 'PINV_Fout') + fcv[f"PW{idx}_PINV_VSplit1"] = get_value(d, 'PINV_VSplit1') + fcv[f"PW{idx}_PINV_VSplit2"] = get_value(d, 'PINV_VSplit2') if device.startswith('TESYNC') or device.startswith('TEMSA'): # Island and Meter Metrics from Backup Gateway or Backup Switch for i in d: @@ -538,7 +546,7 @@ def do_GET(self): if "battery_blocks" in d: for idx, block in enumerate(d["battery_blocks"], start=1): # Vital Placeholders - pod[f"PW{idx}_name" % idx] = None + pod[f"PW{idx}_name"] = None pod[f"PW{idx}_POD_ActiveHeating"] = None pod[f"PW{idx}_POD_ChargeComplete"] = None pod[f"PW{idx}_POD_ChargeRequest"] = None @@ -732,11 +740,11 @@ def do_GET(self): proxystats['gets'] = proxystats['gets'] + 1 if pw.authmode == "token": # Create bogus cookies - self.send_header("Set-Cookie", f"AuthCookie=1234567890;{cookiesuffix}") - self.send_header("Set-Cookie", f"UserRecord=1234567890;{cookiesuffix}") + self.send_header("Set-Cookie", f"AuthCookie=1234567890;{CONFIG[CONFIG_TYPE.PW_COOKIE_SUFFIX]}") + self.send_header("Set-Cookie", f"UserRecord=1234567890;{CONFIG[CONFIG_TYPE.PW_COOKIE_SUFFIX]}") else: - self.send_header("Set-Cookie", f"AuthCookie={pw.client.auth['AuthCookie']};{cookiesuffix}") - self.send_header("Set-Cookie", f"UserRecord={pw.client.auth['UserRecord']};{cookiesuffix}") + self.send_header("Set-Cookie", f"AuthCookie={pw.client.auth['AuthCookie']};{CONFIG[CONFIG_TYPE.PW_COOKIE_SUFFIX]}") + self.send_header("Set-Cookie", f"UserRecord={pw.client.auth['UserRecord']};{CONFIG[CONFIG_TYPE.PW_COOKIE_SUFFIX]}") # Serve static assets from web root first, if found. # pylint: disable=attribute-defined-outside-init @@ -750,8 +758,8 @@ def do_GET(self): # fix the following variables that if they are None, return "" fcontent = fcontent.replace("{VERSION}", status["version"] or "") fcontent = fcontent.replace("{HASH}", status["git_hash"] or "") - fcontent = fcontent.replace("{EMAIL}", email) - fcontent = fcontent.replace("{STYLE}", style) + fcontent = fcontent.replace("{EMAIL}", CONFIG[CONFIG_TYPE.PW_EMAIL]) + fcontent = fcontent.replace("{STYLE}", CONFIG[CONFIG_TYPE.PW_STYLE]) # convert fcontent back to bytes fcontent = bytes(fcontent, 'utf-8') else: @@ -796,15 +804,15 @@ def do_GET(self): ftype = "text/plain" # Allow browser caching, if user permits, only for CSS, JavaScript and PNG images... - if browser_cache > 0 and (ftype == 'text/css' or ftype == 'application/javascript' or ftype == 'image/png'): - self.send_header("Cache-Control", "max-age={}".format(browser_cache)) + if CONFIG[CONFIG_TYPE.PW_BROWSER_CACHE] > 0 and (ftype == 'text/css' or ftype == 'application/javascript' or ftype == 'image/png'): + self.send_header("Cache-Control", "max-age={}".format(CONFIG[CONFIG_TYPE.PW_BROWSER_CACHE])) else: self.send_header("Cache-Control", "no-cache, no-store") # Inject transformations if self.path.split('?')[0] == "/": - if os.path.exists(os.path.join(WEB_ROOT, style)): - fcontent = bytes(inject_js(fcontent, style), 'utf-8') + if os.path.exists(os.path.join(WEB_ROOT, CONFIG[CONFIG_TYPE.PW_STYLE])): + fcontent = bytes(inject_js(fcontent, CONFIG[CONFIG_TYPE.PW_STYLE]), 'utf-8') self.send_header('Content-type', '{}'.format(ftype)) self.end_headers() @@ -844,7 +852,7 @@ def do_GET(self): # noinspection PyTypeChecker with ThreadingHTTPServer((bind_address, port), Handler) as server: - if https_mode == "yes": + if CONFIG[CONFIG_TYPE.PW_HTTPS] == "yes": # Activate HTTPS log.debug("Activating HTTPS") # pylint: disable=deprecated-method From 290da83e3589d4c1c480c5ac8213379f28bd8712 Mon Sep 17 00:00:00 2001 From: Christopher Pitstick Date: Wed, 8 Jan 2025 22:16:29 -0500 Subject: [PATCH 03/35] WIP --- proxy/server.py | 42 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/proxy/server.py b/proxy/server.py index e39643f..3fe6c0e 100755 --- a/proxy/server.py +++ b/proxy/server.py @@ -46,12 +46,11 @@ import sys import time from enum import StrEnum, auto +from http import HTTPStatus from http.server import BaseHTTPRequestHandler, HTTPServer from socketserver import ThreadingMixIn -from typing import Dict, Final, Optional, Set +from typing import Dict, Final, Optional, Set, Any from urllib.parse import parse_qs, urlparse -from http import HTTPStatus - from transform import get_static, inject_js @@ -153,7 +152,8 @@ class CONFIG_TYPE(StrEnum): # Configuration for Proxy - Check for environmental variables # and always use those if available (required for Docker) # Configuration - Environment variables -CONFIG: Dict[CONFIG_TYPE, str | int | bool | None] = { +type PROXY_CONFIG = Dict[CONFIG_TYPE, str | int | bool | None] +CONFIG: PROXY_CONFIG = { CONFIG_TYPE.PW_BIND_ADDRESS: os.getenv(CONFIG_TYPE.PW_BIND_ADDRESS, ""), CONFIG_TYPE.PW_PASSWORD: os.getenv(CONFIG_TYPE.PW_PASSWORD, ""), CONFIG_TYPE.PW_HOST: os.getenv(CONFIG_TYPE.PW_HOST, ""), @@ -207,8 +207,40 @@ def sig_term_handle(signum, frame): signal.signal(signal.SIGTERM, sig_term_handle) + +class PROXY_STATS_TYPE(StrEnum): + """_summary_ + + Args: + StrEnum (_type_): _description_ + """ + + 'pypowerwall': f"{pypowerwall.version} Proxy {BUILD}", + 'mode': "Unknown", + 'gets': 0, + 'posts': 0, + 'errors': 0, + 'timeout': 0, + 'uri': {}, + 'ts': int(time.time()), + 'start': int(time.time()), + 'clear': int(time.time()), + 'uptime': "", + 'mem': 0, + 'site_name': "", + 'cloudmode': False, + 'fleetapi': False, + 'tedapi': False, + 'pw3': False, + 'tedapi_mode': "off", + 'siteid': None, + 'counter': 0, + 'cf': CONFIG[CONFIG_TYPE.PW_CACHE_FILE], + 'config': CONFIG.copy() + + # Global Stats -proxystats = { +proxystats: Dict[PROXY_STATS_TYPE, str | int | bool | None | Dict[Any, Any]] = { 'pypowerwall': f"{pypowerwall.version} Proxy {BUILD}", 'mode': "Unknown", 'gets': 0, From e52bfaffec7e6e4c5175fce8a04d906e40a5a2ec Mon Sep 17 00:00:00 2001 From: Christopher Pitstick Date: Thu, 9 Jan 2025 20:23:27 -0500 Subject: [PATCH 04/35] Create proxy stats type --- proxy/server.py | 176 ++++++++++++++++++++++++------------------------ 1 file changed, 88 insertions(+), 88 deletions(-) diff --git a/proxy/server.py b/proxy/server.py index 3fe6c0e..0b35ca3 100755 --- a/proxy/server.py +++ b/proxy/server.py @@ -214,55 +214,55 @@ class PROXY_STATS_TYPE(StrEnum): Args: StrEnum (_type_): _description_ """ - - 'pypowerwall': f"{pypowerwall.version} Proxy {BUILD}", - 'mode': "Unknown", - 'gets': 0, - 'posts': 0, - 'errors': 0, - 'timeout': 0, - 'uri': {}, - 'ts': int(time.time()), - 'start': int(time.time()), - 'clear': int(time.time()), - 'uptime': "", - 'mem': 0, - 'site_name': "", - 'cloudmode': False, - 'fleetapi': False, - 'tedapi': False, - 'pw3': False, - 'tedapi_mode': "off", - 'siteid': None, - 'counter': 0, - 'cf': CONFIG[CONFIG_TYPE.PW_CACHE_FILE], - 'config': CONFIG.copy() + AUTH_MODE = auto() + PYPOWERWALL = auto() + MODE = auto() + GETS = auto() + POSTS = auto() + ERRORS = auto() + TIMEOUT = auto() + URI = auto() + TS = auto() + START = auto() + CLEAR = auto() + UPTIME = auto() + MEM = auto() + SITE_NAME = auto() + CLOUDMODE = auto() + FLEETAPI = auto() + TEDAPI = auto() + PW3 = auto() + TEDAPI_MODE = auto() + SITEID = auto() + COUNTER = auto() + CF = auto() + CONFIG = auto() # Global Stats proxystats: Dict[PROXY_STATS_TYPE, str | int | bool | None | Dict[Any, Any]] = { - 'pypowerwall': f"{pypowerwall.version} Proxy {BUILD}", - 'mode': "Unknown", - 'gets': 0, - 'posts': 0, - 'errors': 0, - 'timeout': 0, - 'uri': {}, - 'ts': int(time.time()), - 'start': int(time.time()), - 'clear': int(time.time()), - 'uptime': "", - 'mem': 0, - 'site_name': "", - 'cloudmode': False, - 'fleetapi': False, - 'tedapi': False, - 'pw3': False, - 'tedapi_mode': "off", - 'siteid': None, - 'counter': 0, - 'cf': CONFIG[CONFIG_TYPE.PW_CACHE_FILE], - 'config': CONFIG.copy() + PROXY_STATS_TYPE.PYPOWERWALL: f"{pypowerwall.version} Proxy {BUILD}", + PROXY_STATS_TYPE.MODE: "Unknown", + PROXY_STATS_TYPE.GETS: 0, + PROXY_STATS_TYPE.POSTS: 0, + PROXY_STATS_TYPE.ERRORS: 0, + PROXY_STATS_TYPE.TIMEOUT: 0, + PROXY_STATS_TYPE.URI: {}, + PROXY_STATS_TYPE.TS: int(time.time()), + PROXY_STATS_TYPE.START: int(time.time()), + PROXY_STATS_TYPE.CLEAR: int(time.time()), + PROXY_STATS_TYPE.UPTIME: "", + PROXY_STATS_TYPE.MEM: 0, + PROXY_STATS_TYPE.SITE_NAME: "", + PROXY_STATS_TYPE.CLOUDMODE: False, + PROXY_STATS_TYPE.FLEETAPI: False, + PROXY_STATS_TYPE.TEDAPI: False, + PROXY_STATS_TYPE.PW3: False, + PROXY_STATS_TYPE.TEDAPI_MODE: "off", + PROXY_STATS_TYPE.SITEID: None, + PROXY_STATS_TYPE.COUNTER: 0, + PROXY_STATS_TYPE.CF: CONFIG[CONFIG_TYPE.PW_CACHE_FILE], + PROXY_STATS_TYPE.CONFIG: CONFIG.copy() } log.info( @@ -312,10 +312,10 @@ def get_value(a, key): site_name = pw.site_name() or "Unknown" if pw.cloudmode or pw.fleetapi: if pw.fleetapi: - proxystats['mode'] = "FleetAPI" + proxystats[PROXY_STATS_TYPE.MODE] = "FleetAPI" log.info("pyPowerwall Proxy Server - FleetAPI Mode") else: - proxystats['mode'] = "Cloud" + proxystats[PROXY_STATS_TYPE.MODE] = "Cloud" log.info("pyPowerwall Proxy Server - Cloud Mode") log.info(f"Connected to Site ID {pw.client.siteid} ({site_name.strip()})") if CONFIG[CONFIG_TYPE.PW_SITEID] is not None and CONFIG[CONFIG_TYPE.PW_SITEID] != str(pw.client.siteid): @@ -328,13 +328,13 @@ def get_value(a, key): except (KeyboardInterrupt, SystemExit): sys.exit(0) else: - proxystats['mode'] = "Local" + proxystats[PROXY_STATS_TYPE.MODE] = "Local" log.info("pyPowerwall Proxy Server - Local Mode") log.info(f"Connected to Energy Gateway {CONFIG[CONFIG_TYPE.PW_HOST]} ({site_name.strip()})") if pw.tedapi: - proxystats['tedapi'] = True - proxystats['tedapi_mode'] = pw.tedapi_mode - proxystats['pw3'] = pw.tedapi.pw3 + proxystats[PROXY_STATS_TYPE.TEDAPI] = True + proxystats[PROXY_STATS_TYPE.TEDAPI_MODE] = pw.tedapi_mode + proxystats[PROXY_STATS_TYPE.PW3] = pw.tedapi.pw3 log.info(f"TEDAPI Mode Enabled for Device Vitals ({pw.tedapi_mode})") pw_control = None @@ -426,12 +426,12 @@ def do_POST(self): message = '{"unauthorized": "Control Command Token Invalid"}' if "error" in message: self.send_response(HTTPStatus.BAD_REQUEST) - proxystats['errors'] = proxystats['errors'] + 1 + proxystats[PROXY_STATS_TYPE.ERRORS] += 1 elif "unauthorized" in message: self.send_response(HTTPStatus.UNAUTHORIZED) else: self.send_response(HTTPStatus.OK) - proxystats['posts'] = proxystats['posts'] + 1 + proxystats[PROXY_STATS_TYPE.POSTS] += 1 self.send_header('Content-type', contenttype) self.send_header('Content-Length', str(len(message))) self.send_header("Access-Control-Allow-Origin", "*") @@ -490,24 +490,24 @@ def do_GET(self): message: str = pw.strings(jsonformat=True) or json.dumps({}) elif self.path == '/stats': # Give Internal Stats - proxystats['ts'] = int(time.time()) - delta = proxystats['ts'] - proxystats['start'] - proxystats['uptime'] = str(datetime.timedelta(seconds=delta)) - proxystats['mem'] = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss - proxystats['site_name'] = pw.site_name() - proxystats['cloudmode'] = pw.cloudmode - proxystats['fleetapi'] = pw.fleetapi + proxystats[PROXY_STATS_TYPE.TS] = int(time.time()) + delta = proxystats[PROXY_STATS_TYPE.TS] - proxystats['start'] + proxystats[PROXY_STATS_TYPE.UPTIME] = str(datetime.timedelta(seconds=delta)) + proxystats[PROXY_STATS_TYPE.MEM] = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss + proxystats[PROXY_STATS_TYPE.SITE_NAME] = pw.site_name() + proxystats[PROXY_STATS_TYPE.CLOUDMODE] = pw.cloudmode + proxystats[PROXY_STATS_TYPE.FLEETAPI] = pw.fleetapi if (pw.cloudmode or pw.fleetapi) and pw.client is not None: - proxystats['siteid'] = pw.client.siteid - proxystats['counter'] = pw.client.counter + proxystats[PROXY_STATS_TYPE.SITEID] = pw.client.siteid + proxystats[PROXY_STATS_TYPE.COUNTER] = pw.client.counter message: str = json.dumps(proxystats) elif self.path == '/stats/clear': # Clear Internal Stats log.debug("Clear internal stats") - proxystats['gets'] = 0 - proxystats['errors'] = 0 - proxystats['uri'] = {} - proxystats['clear'] = int(time.time()) + proxystats[PROXY_STATS_TYPE.GETS] = 0 + proxystats[PROXY_STATS_TYPE.ERRORS] = 0 + proxystats[PROXY_STATS_TYPE.URI] = {} + proxystats[PROXY_STATS_TYPE.CLEAR] = int(time.time()) message: str = json.dumps(proxystats) elif self.path == '/temps': # Temps of Powerwalls @@ -651,17 +651,17 @@ def do_GET(self): message: str = json.dumps(v) elif self.path == '/help': # Display friendly help screen link and stats - proxystats['ts'] = int(time.time()) - delta = proxystats['ts'] - proxystats['start'] - proxystats['uptime'] = str(datetime.timedelta(seconds=delta)) - proxystats['mem'] = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss - proxystats['site_name'] = pw.site_name() - proxystats['cloudmode'] = pw.cloudmode - proxystats['fleetapi'] = pw.fleetapi + proxystats[PROXY_STATS_TYPE.TS] = int(time.time()) + delta = proxystats[PROXY_STATS_TYPE.TS] - proxystats[PROXY_STATS_TYPE.START] + proxystats[PROXY_STATS_TYPE.UPTIME] = str(datetime.timedelta(seconds=delta)) + proxystats[PROXY_STATS_TYPE.MEM] = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss + proxystats[PROXY_STATS_TYPE.SITE_NAME] = pw.site_name() + proxystats[PROXY_STATS_TYPE.CLOUDMODE] = pw.cloudmode + proxystats[PROXY_STATS_TYPE.FLEETAPI] = pw.fleetapi if (pw.cloudmode or pw.fleetapi) and pw.client is not None: - proxystats['siteid'] = pw.client.siteid - proxystats['counter'] = pw.client.counter - proxystats['authmode'] = pw.authmode + proxystats[PROXY_STATS_TYPE.SITEID] = pw.client.siteid + proxystats[PROXY_STATS_TYPE.COUNTER] = pw.client.counter + proxystats[PROXY_STATS_TYPE.AUTH_MODE] = pw.authmode contenttype = 'text/html' message: str = """ \n\n @@ -674,10 +674,10 @@ def do_GET(self): """ message = message.replace('%VER%', pypowerwall.version).replace('%BUILD%', BUILD) for i in proxystats: - if i != 'uri' and i != 'config': + if i != PROXY_STATS_TYPE.URI and i != PROXY_STATS_TYPE.CONFIG: message += f'{i}{proxystats[i]}\n' - for i in proxystats['uri']: - message += f'URI: {i}{proxystats["uri"][i]}\n' + for i in proxystats[PROXY_STATS_TYPE.URI]: + message += f'URI: {i}{proxystats[PROXY_STATS_TYPE.URI][i]}\n' message += """ Config: @@ -686,8 +686,8 @@ def do_GET(self): Click to view """ - for i in proxystats['config']: - message += f'\n' + for i in proxystats[PROXY_STATS_TYPE.CONFIG]: + message += f'\n' message += """
{i}{proxystats["config"][i]}
{i}{proxystats[PROXY_STATS_TYPE.CONFIG][i]}
@@ -769,7 +769,7 @@ def do_GET(self): message = '{"mode": "%s"}' % pw_control.get_mode() else: # Everything else - Set auth headers required for web application - proxystats['gets'] = proxystats['gets'] + 1 + proxystats[PROXY_STATS_TYPE.GETS] += 1 if pw.authmode == "token": # Create bogus cookies self.send_header("Set-Cookie", f"AuthCookie=1234567890;{CONFIG[CONFIG_TYPE.PW_COOKIE_SUFFIX]}") @@ -859,17 +859,17 @@ def do_GET(self): # Count if message is None: - proxystats['timeout'] = proxystats['timeout'] + 1 + proxystats[PROXY_STATS_TYPE.TIMEOUT] += 1 message = "TIMEOUT!" elif message == "ERROR!": - proxystats['errors'] = proxystats['errors'] + 1 + proxystats[PROXY_STATS_TYPE.ERRORS] += 1 message = "ERROR!" else: - proxystats['gets'] = proxystats['gets'] + 1 - if self.path in proxystats['uri']: - proxystats['uri'][self.path] = proxystats['uri'][self.path] + 1 + proxystats[PROXY_STATS_TYPE.GETS] += 1 + if self.path in proxystats[PROXY_STATS_TYPE.URI]: + proxystats[PROXY_STATS_TYPE.URI][self.path] += 1 else: - proxystats['uri'][self.path] = 1 + proxystats[PROXY_STATS_TYPE.URI][self.path] = 1 # Send headers and payload try: From 29319d26779230a0615434de33b8263de2c985f4 Mon Sep 17 00:00:00 2001 From: Christopher Pitstick Date: Thu, 9 Jan 2025 21:02:49 -0500 Subject: [PATCH 05/35] Continue to refactor --- proxy/server.py | 214 ++++++++++++++++++++++++++---------------------- 1 file changed, 114 insertions(+), 100 deletions(-) diff --git a/proxy/server.py b/proxy/server.py index 0b35ca3..6670d67 100755 --- a/proxy/server.py +++ b/proxy/server.py @@ -49,7 +49,7 @@ from http import HTTPStatus from http.server import BaseHTTPRequestHandler, HTTPServer from socketserver import ThreadingMixIn -from typing import Dict, Final, Optional, Set, Any +from typing import Any, Dict, Final, Optional, Set from urllib.parse import parse_qs, urlparse from transform import get_static, inject_js @@ -60,32 +60,32 @@ BUILD = "t67" ALLOWLIST = Set[str] = set([ - '/api/status', - '/api/site_info/site_name', + '/api/auth/toggle/supported', + '/api/customer', + '/api/customer/registration', + '/api/installer', + '/api/meters', + '/api/meters/readings', '/api/meters/site', '/api/meters/solar', - '/api/sitemaster', + '/api/networks', + '/api/operation', '/api/powerwalls', - '/api/customer/registration', - '/api/system_status', - '/api/system_status/grid_status', - '/api/system/update/status', '/api/site_info', - '/api/system_status/grid_faults', - '/api/operation', '/api/site_info/grid_codes', + '/api/site_info/site_name', + '/api/sitemaster', + '/api/solar_powerwall', '/api/solars', '/api/solars/brands', - '/api/customer', - '/api/meters', - '/api/installer', - '/api/networks', - '/api/system/networks', - '/api/meters/readings', + '/api/status', '/api/synchrometer/ct_voltage_references', + '/api/system_status', + '/api/system_status/grid_faults', + '/api/system_status/grid_status', + '/api/system/networks', + '/api/system/update/status', '/api/troubleshooting/problems', - '/api/auth/toggle/supported', - '/api/solar_powerwall', ]) DISABLED = Set[str] = set([ @@ -125,54 +125,54 @@ class CONFIG_TYPE(StrEnum): Args: StrEnum (_type_): _description_ """ + PW_AUTH_MODE = auto() + PW_AUTH_PATH = auto() + PW_AUTH_PATH = auto() PW_BIND_ADDRESS = auto() - PW_PASSWORD = auto() + PW_BROWSER_CACHE = auto() + PW_CACHE_EXPIRE = auto() + PW_CACHE_FILE = auto() + PW_CONTROL_SECRET = auto() + PW_COOKIE_SUFFIX = auto() + PW_DEBUG = auto() PW_EMAIL = auto() + PW_GW_PWD = auto() PW_HOST = auto() - PW_TIMEZONE = auto() - PW_DEBUG = auto() - PW_CACHE_EXPIRE = auto() - PW_BROWSER_CACHE = auto() - PW_TIMEOUT = auto() - PW_POOL_MAXSIZE = auto() - PW_HTTPS = auto() PW_HTTP_TYPE = auto() + PW_HTTPS = auto() + PW_NEG_SOLAR = auto() + PW_PASSWORD = auto() + PW_POOL_MAXSIZE = auto() PW_PORT = auto() - PW_STYLE = auto() PW_SITEID = auto() - PW_AUTH_PATH = auto() - PW_AUTH_MODE = auto() - PW_CONTROL_SECRET = auto() - PW_GW_PWD = auto() - PW_NEG_SOLAR = auto() - PW_CACHE_FILE = auto() - PW_AUTH_PATH = auto() - PW_COOKIE_SUFFIX = auto() + PW_STYLE = auto() + PW_TIMEOUT = auto() + PW_TIMEZONE = auto() # Configuration for Proxy - Check for environmental variables # and always use those if available (required for Docker) # Configuration - Environment variables type PROXY_CONFIG = Dict[CONFIG_TYPE, str | int | bool | None] CONFIG: PROXY_CONFIG = { + CONFIG_TYPE.PW_AUTH_MODE: os.getenv(CONFIG_TYPE.PW_AUTH_MODE, "cookie"), + CONFIG_TYPE.PW_AUTH_PATH: os.getenv(CONFIG_TYPE.PW_AUTH_PATH, ""), CONFIG_TYPE.PW_BIND_ADDRESS: os.getenv(CONFIG_TYPE.PW_BIND_ADDRESS, ""), - CONFIG_TYPE.PW_PASSWORD: os.getenv(CONFIG_TYPE.PW_PASSWORD, ""), - CONFIG_TYPE.PW_HOST: os.getenv(CONFIG_TYPE.PW_HOST, ""), + CONFIG_TYPE.PW_BROWSER_CACHE: int(os.getenv(CONFIG_TYPE.PW_BROWSER_CACHE, "0")), + CONFIG_TYPE.PW_CACHE_EXPIRE: int(os.getenv(CONFIG_TYPE.PW_CACHE_EXPIRE, "5")), CONFIG_TYPE.PW_CONTROL_SECRET: os.getenv(CONFIG_TYPE.PW_CONTROL_SECRET, ""), + CONFIG_TYPE.PW_DEBUG: bool(os.getenv(CONFIG_TYPE.PW_DEBUG, "no").lower() == "yes"), CONFIG_TYPE.PW_EMAIL: os.getenv(CONFIG_TYPE.PW_EMAIL, "email@example.com"), - CONFIG_TYPE.PW_TIMEZONE: os.getenv(CONFIG_TYPE.PW_TIMEZONE, "America/Los_Angeles"), - CONFIG_TYPE.PW_CACHE_EXPIRE: int(os.getenv(CONFIG_TYPE.PW_CACHE_EXPIRE, "5")), - CONFIG_TYPE.PW_BROWSER_CACHE: int(os.getenv(CONFIG_TYPE.PW_BROWSER_CACHE, "0")), - CONFIG_TYPE.PW_TIMEOUT: int(os.getenv(CONFIG_TYPE.PW_TIMEOUT, "5")), - CONFIG_TYPE.PW_POOL_MAXSIZE: int(os.getenv(CONFIG_TYPE.PW_POOL_MAXSIZE, "15")), + CONFIG_TYPE.PW_GW_PWD: os.getenv(CONFIG_TYPE.PW_GW_PWD, None), + CONFIG_TYPE.PW_HOST: os.getenv(CONFIG_TYPE.PW_HOST, ""), CONFIG_TYPE.PW_HTTPS: os.getenv(CONFIG_TYPE.PW_HTTPS, "no"), + CONFIG_TYPE.PW_NEG_SOLAR: bool(os.getenv(CONFIG_TYPE.PW_NEG_SOLAR, "yes").lower() == "yes"), + CONFIG_TYPE.PW_PASSWORD: os.getenv(CONFIG_TYPE.PW_PASSWORD, ""), + CONFIG_TYPE.PW_POOL_MAXSIZE: int(os.getenv(CONFIG_TYPE.PW_POOL_MAXSIZE, "15")), CONFIG_TYPE.PW_PORT: int(os.getenv(CONFIG_TYPE.PW_PORT, "8675")), - CONFIG_TYPE.PW_STYLE: os.getenv(CONFIG_TYPE.PW_STYLE, "clear") + ".js", CONFIG_TYPE.PW_SITEID: os.getenv(CONFIG_TYPE.PW_SITEID, None), - CONFIG_TYPE.PW_AUTH_PATH: os.getenv(CONFIG_TYPE.PW_AUTH_PATH, ""), - CONFIG_TYPE.PW_AUTH_MODE: os.getenv(CONFIG_TYPE.PW_AUTH_MODE, "cookie"), - CONFIG_TYPE.PW_GW_PWD: os.getenv(CONFIG_TYPE.PW_GW_PWD, None), - CONFIG_TYPE.PW_DEBUG: bool(os.getenv(CONFIG_TYPE.PW_DEBUG, "no").lower() == "yes"), - CONFIG_TYPE.PW_NEG_SOLAR: bool(os.getenv(CONFIG_TYPE.PW_NEG_SOLAR, "yes").lower() == "yes") + CONFIG_TYPE.PW_STYLE: os.getenv(CONFIG_TYPE.PW_STYLE, "clear") + ".js", + CONFIG_TYPE.PW_TIMEOUT: int(os.getenv(CONFIG_TYPE.PW_TIMEOUT, "5")), + CONFIG_TYPE.PW_TIMEZONE: os.getenv(CONFIG_TYPE.PW_TIMEZONE, "America/Los_Angeles") } # Cache file @@ -215,54 +215,54 @@ class PROXY_STATS_TYPE(StrEnum): StrEnum (_type_): _description_ """ AUTH_MODE = auto() - PYPOWERWALL = auto() - MODE = auto() - GETS = auto() - POSTS = auto() - ERRORS = auto() - TIMEOUT = auto() - URI = auto() - TS = auto() - START = auto() + CF = auto() CLEAR = auto() - UPTIME = auto() - MEM = auto() - SITE_NAME = auto() CLOUDMODE = auto() + CONFIG = auto() + COUNTER = auto() + ERRORS = auto() FLEETAPI = auto() - TEDAPI = auto() + GETS = auto() + MEM = auto() + MODE = auto() + POSTS = auto() PW3 = auto() - TEDAPI_MODE = auto() + PYPOWERWALL = auto() + SITE_NAME = auto() SITEID = auto() - COUNTER = auto() - CF = auto() - CONFIG = auto() + START = auto() + TEDAPI = auto() + TEDAPI_MODE = auto() + TIMEOUT = auto() + TS = auto() + UPTIME = auto() + URI = auto() # Global Stats proxystats: Dict[PROXY_STATS_TYPE, str | int | bool | None | Dict[Any, Any]] = { - PROXY_STATS_TYPE.PYPOWERWALL: f"{pypowerwall.version} Proxy {BUILD}", - PROXY_STATS_TYPE.MODE: "Unknown", - PROXY_STATS_TYPE.GETS: 0, - PROXY_STATS_TYPE.POSTS: 0, - PROXY_STATS_TYPE.ERRORS: 0, - PROXY_STATS_TYPE.TIMEOUT: 0, - PROXY_STATS_TYPE.URI: {}, - PROXY_STATS_TYPE.TS: int(time.time()), - PROXY_STATS_TYPE.START: int(time.time()), + PROXY_STATS_TYPE.CF: CONFIG[CONFIG_TYPE.PW_CACHE_FILE], PROXY_STATS_TYPE.CLEAR: int(time.time()), - PROXY_STATS_TYPE.UPTIME: "", - PROXY_STATS_TYPE.MEM: 0, - PROXY_STATS_TYPE.SITE_NAME: "", PROXY_STATS_TYPE.CLOUDMODE: False, + PROXY_STATS_TYPE.CONFIG: CONFIG.copy(), + PROXY_STATS_TYPE.COUNTER: 0, + PROXY_STATS_TYPE.ERRORS: 0, PROXY_STATS_TYPE.FLEETAPI: False, - PROXY_STATS_TYPE.TEDAPI: False, + PROXY_STATS_TYPE.GETS: 0, + PROXY_STATS_TYPE.MEM: 0, + PROXY_STATS_TYPE.MODE: "Unknown", + PROXY_STATS_TYPE.POSTS: 0, PROXY_STATS_TYPE.PW3: False, - PROXY_STATS_TYPE.TEDAPI_MODE: "off", + PROXY_STATS_TYPE.PYPOWERWALL: f"{pypowerwall.version} Proxy {BUILD}", + PROXY_STATS_TYPE.SITE_NAME: "", PROXY_STATS_TYPE.SITEID: None, - PROXY_STATS_TYPE.COUNTER: 0, - PROXY_STATS_TYPE.CF: CONFIG[CONFIG_TYPE.PW_CACHE_FILE], - PROXY_STATS_TYPE.CONFIG: CONFIG.copy() + PROXY_STATS_TYPE.START: int(time.time()), + PROXY_STATS_TYPE.TEDAPI_MODE: "off", + PROXY_STATS_TYPE.TEDAPI: False, + PROXY_STATS_TYPE.TIMEOUT: 0, + PROXY_STATS_TYPE.TS: int(time.time()), + PROXY_STATS_TYPE.UPTIME: "", + PROXY_STATS_TYPE.URI: {} } log.info( @@ -337,24 +337,38 @@ def get_value(a, key): proxystats[PROXY_STATS_TYPE.PW3] = pw.tedapi.pw3 log.info(f"TEDAPI Mode Enabled for Device Vitals ({pw.tedapi_mode})") -pw_control = None -if CONFIG[CONFIG_TYPE.PW_CONTROL_SECRET]: +def configure_pw_control(pw): + if not CONFIG[CONFIG_TYPE.PW_CONTROL_SECRET]: + return None + log.info("Control Commands Activating - WARNING: Use with caution!") try: if pw.cloudmode or pw.fleetapi: pw_control = pw else: - pw_control = pypowerwall.Powerwall("", password, email, siteid=siteid, - authpath=authpath, authmode=authmode, - cachefile=cachefile, auto_select=True) + pw_control = pypowerwall.Powerwall( + "", + CONFIG['PW_PASSWORD'], + CONFIG['PW_EMAIL'], + siteid=CONFIG['PW_SITEID'], + authpath=CONFIG['PW_AUTH_PATH'], + authmode=CONFIG['PW_AUTH_MODE'], + cachefile=CONFIG['PW_CACHE_FILE'], + auto_select=True + ) except Exception as e: log.error("Control Mode Failed: Unable to connect to cloud - Run Setup") - CONFIG[CONFIG_TYPE.PW_CONTROL_SECRET] = "" + CONFIG[CONFIG_TYPE.PW_CONTROL_SECRET] = None + return None + if pw_control: log.info(f"Control Mode Enabled: Cloud Mode ({pw_control.mode}) Connected") else: log.error("Control Mode Failed: Unable to connect to cloud - Run Setup") CONFIG[CONFIG_TYPE.PW_CONTROL_SECRET] = None + return None + + return pw_control class ThreadingHTTPServer(ThreadingMixIn, HTTPServer): daemon_threads = True @@ -366,8 +380,6 @@ class Handler(BaseHTTPRequestHandler): def log_message(self, log_format, *args): if CONFIG[CONFIG_TYPE.PW_DEBUG]: log.debug("%s %s" % (self.address_string(), log_format % args)) - else: - pass def address_string(self): # replace function to avoid lookup delays @@ -440,7 +452,7 @@ def do_POST(self): def do_GET(self): global proxystats - self.send_response(200) + self.send_response(HTTPStatus.OK) contenttype = 'application/json' if self.path == '/aggregates' or self.path == '/api/meters/aggregates': @@ -515,12 +527,10 @@ def do_GET(self): elif self.path == '/temps/pw': # Temps of Powerwalls with Simple Keys pwtemp = {} - idx = 1 temps = pw.temps() - for i in temps: - key = "PW%d_temp" % idx + for idx, i in enumerate(temps, start=1): + key = f"PW{idx}_temp" pwtemp[key] = temps[i] - idx = idx + 1 message: str = json.dumps(pwtemp) elif self.path == '/alerts': # Alerts @@ -883,15 +893,19 @@ def do_GET(self): # noinspection PyTypeChecker -with ThreadingHTTPServer((bind_address, port), Handler) as server: +with ThreadingHTTPServer((CONFIG[CONFIG_TYPE.PW_BIND_ADDRESS], CONFIG[CONFIG_TYPE.PW_PORT]), Handler) as server: if CONFIG[CONFIG_TYPE.PW_HTTPS] == "yes": # Activate HTTPS log.debug("Activating HTTPS") # pylint: disable=deprecated-method - server.socket = ssl.wrap_socket(server.socket, - certfile=os.path.join(os.path.dirname(__file__), 'localhost.pem'), - server_side=True, ssl_version=ssl.PROTOCOL_TLSv1_2, ca_certs=None, - do_handshake_on_connect=True) + server.socket = ssl.wrap_socket( + server.socket, + certfile=os.path.join(os.path.dirname(__file__), 'localhost.pem'), + server_side=True, + ssl_version=ssl.PROTOCOL_TLSv1_2, + ca_certs=None, + do_handshake_on_connect=True + ) # noinspection PyBroadException try: From 9a90606fc50a40f6d6c1672f16df1316ffe029de Mon Sep 17 00:00:00 2001 From: Christopher Pitstick Date: Thu, 9 Jan 2025 22:12:49 -0500 Subject: [PATCH 06/35] Continuing to work on refactoring --- proxy/server.py | 567 ++++++++++++++++++++++++++---------------------- 1 file changed, 310 insertions(+), 257 deletions(-) diff --git a/proxy/server.py b/proxy/server.py index 6670d67..975f00d 100755 --- a/proxy/server.py +++ b/proxy/server.py @@ -281,34 +281,6 @@ def get_value(a, key): log.debug(f"Missing key in payload [{key}]") return value -# Connect to Powerwall -# TODO: Add support for multiple Powerwalls -try: - pw = pypowerwall.Powerwall( - host=CONFIG[CONFIG_TYPE.PW_HOST], - password=CONFIG[CONFIG_TYPE.PW_PASSWORD], - email=CONFIG[CONFIG_TYPE.PW_EMAIL], - timezone=CONFIG[CONFIG_TYPE.PW_TIMEZONE], - cache_expire=CONFIG[CONFIG_TYPE.PW_CACHE_EXPIRE], - timeout=CONFIG[CONFIG_TYPE.PW_TIMEOUT], - pool_maxsize=CONFIG[CONFIG_TYPE.PW_POOL_MAXSIZE], - siteid=CONFIG[CONFIG_TYPE.PW_SITEID], - authpath=CONFIG[CONFIG_TYPE.PW_AUTH_PATH], - authmode=CONFIG[CONFIG_TYPE.PW_AUTH_MODE], - cachefile=CONFIG[CONFIG_TYPE.PW_CACHE_FILE], - auto_select=True, - retry_modes=True, - gw_pwd=CONFIG[CONFIG_TYPE.PW_GW_PWD] - ) -except Exception as e: - log.error(e) - log.error("Fatal Error: Unable to connect. Please fix config and restart.") - while True: - try: - time.sleep(5) # Infinite loop to keep container running - except (KeyboardInterrupt, SystemExit): - sys.exit(0) - site_name = pw.site_name() or "Unknown" if pw.cloudmode or pw.fleetapi: if pw.fleetapi: @@ -373,7 +345,6 @@ def configure_pw_control(pw): class ThreadingHTTPServer(ThreadingMixIn, HTTPServer): daemon_threads = True - # pylint: disable=arguments-differ,global-variable-not-assigned # noinspection PyPep8Naming class Handler(BaseHTTPRequestHandler): @@ -385,6 +356,18 @@ def address_string(self): # replace function to avoid lookup delays hostaddr, hostport = self.client_address[:2] return hostaddr + + def send_json_response(self, data, status_code=HTTPStatus.OK, content_type='application/json'): + response = json.dumps(data) + try: + self.send_response(status_code) + self.send_header('Content-type', content_type) + self.send_header('Content-Length', str(len(response))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(response.encode("utf8")) + except Exception as exc: + log.debug("Error sending response: %s", exc) def do_POST(self): global proxystats @@ -451,214 +434,254 @@ def do_POST(self): self.wfile.write(message.encode("utf8")) def do_GET(self): - global proxystats - self.send_response(HTTPStatus.OK) - contenttype = 'application/json' - - if self.path == '/aggregates' or self.path == '/api/meters/aggregates': - # Meters - JSON - aggregates = pw.poll('/api/meters/aggregates') - if not CONFIG[CONFIG_TYPE.PW_NEG_SOLAR] and aggregates and 'solar' in aggregates: - solar = aggregates['solar'] - if solar and 'instant_power' in solar and solar['instant_power'] < 0: - solar['instant_power'] = 0 - # Shift energy from solar to load - if 'load' in aggregates and 'instant_power' in aggregates['load']: - aggregates['load']['instant_power'] -= solar['instant_power'] - try: - message = json.dumps(aggregates) - except: - log.error(f"JSON encoding error in payload: {aggregates}") - message = None - elif self.path == '/soe': - # Battery Level - JSON - message: str = pw.poll('/api/system_status/soe', jsonformat=True) - elif self.path == '/api/system_status/soe': - # Force 95% Scale - level = pw.level(scale=True) - message: str = json.dumps({"percentage": level}) - elif self.path == '/api/system_status/grid_status': - # Grid Status - JSON - message: str = pw.poll('/api/system_status/grid_status', jsonformat=True) - elif self.path == '/csv': - # Grid,Home,Solar,Battery,Level - CSV - contenttype = 'text/plain; charset=utf-8' - batterylevel = pw.level() or 0 - grid = pw.grid() or 0 - solar = pw.solar() or 0 - battery = pw.battery() or 0 - home = pw.home() or 0 - if not CONFIG[CONFIG_TYPE.PW_NEG_SOLAR] and solar < 0: - solar = 0 + """Handle GET requests.""" + proxystats[PROXY_STATS_TYPE.GETS] += 1 + parsed_path = urlparse(self.path) + path = parsed_path.path + + # Map paths to handler functions + path_handlers = { + '/aggregates': self.handle_aggregates, + '/api/meters/aggregates': self.handle_aggregates, + '/soe': self.handle_soe, + '/api/system_status/soe': self.handle_soe_scaled, + '/api/system_status/grid_status': self.handle_grid_status, + '/csv': self.handle_csv, + '/vitals': self.handle_vitals, + '/strings': self.handle_strings, + '/stats': self.handle_stats, + '/stats/clear': self.handle_stats_clear, + '/temps': self.handle_temps, + '/temps/pw': self.handle_temps_pw, + '/alerts': self.handle_alerts, + '/alerts/pw': self.handle_alerts_pw, + '/freq': self.handle_freq, + '/pod': self.handle_pod, + '/version': self.handle_version, + '/help': self.handle_help, + '/api/troubleshooting/problems': self.handle_problems, + } + + if path in path_handlers: + path_handlers[path]() + elif path in DISABLED: + self.send_json_response( + {"status": "404 Response - API Disabled"}, + status_code=HTTPStatus.NOT_FOUND + ) + elif path in ALLOWLIST: + self.handle_allowlist(path) + elif path.startswith('/tedapi'): + self.handle_tedapi(path) + elif path.startswith('/cloud'): + self.handle_cloud(path) + elif path.startswith('/fleetapi'): + self.handle_fleetapi(path) + else: + self.handle_static_content() + + def handle_aggregates(self): + # Meters - JSON + aggregates = pw.poll('/api/meters/aggregates') + if not CONFIG[CONFIG_TYPE.PW_NEG_SOLAR] and aggregates and 'solar' in aggregates: + solar = aggregates['solar'] + if solar and 'instant_power' in solar and solar['instant_power'] < 0: + solar['instant_power'] = 0 # Shift energy from solar to load - home -= solar - message = "%0.2f,%0.2f,%0.2f,%0.2f,%0.2f\n" \ - % (grid, home, solar, battery, batterylevel) - elif self.path == '/vitals': - # Vitals Data - JSON - message: str = pw.vitals(jsonformat=True) or json.dumps({}) - elif self.path == '/strings': - # Strings Data - JSON - message: str = pw.strings(jsonformat=True) or json.dumps({}) - elif self.path == '/stats': - # Give Internal Stats - proxystats[PROXY_STATS_TYPE.TS] = int(time.time()) - delta = proxystats[PROXY_STATS_TYPE.TS] - proxystats['start'] - proxystats[PROXY_STATS_TYPE.UPTIME] = str(datetime.timedelta(seconds=delta)) - proxystats[PROXY_STATS_TYPE.MEM] = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss - proxystats[PROXY_STATS_TYPE.SITE_NAME] = pw.site_name() - proxystats[PROXY_STATS_TYPE.CLOUDMODE] = pw.cloudmode - proxystats[PROXY_STATS_TYPE.FLEETAPI] = pw.fleetapi - if (pw.cloudmode or pw.fleetapi) and pw.client is not None: - proxystats[PROXY_STATS_TYPE.SITEID] = pw.client.siteid - proxystats[PROXY_STATS_TYPE.COUNTER] = pw.client.counter - message: str = json.dumps(proxystats) - elif self.path == '/stats/clear': - # Clear Internal Stats - log.debug("Clear internal stats") - proxystats[PROXY_STATS_TYPE.GETS] = 0 - proxystats[PROXY_STATS_TYPE.ERRORS] = 0 - proxystats[PROXY_STATS_TYPE.URI] = {} - proxystats[PROXY_STATS_TYPE.CLEAR] = int(time.time()) - message: str = json.dumps(proxystats) - elif self.path == '/temps': - # Temps of Powerwalls - message: str = pw.temps(jsonformat=True) or json.dumps({}) - elif self.path == '/temps/pw': - # Temps of Powerwalls with Simple Keys - pwtemp = {} - temps = pw.temps() - for idx, i in enumerate(temps, start=1): - key = f"PW{idx}_temp" - pwtemp[key] = temps[i] - message: str = json.dumps(pwtemp) - elif self.path == '/alerts': - # Alerts - message: str = pw.alerts(jsonformat=True) or json.dumps([]) - elif self.path == '/alerts/pw': - # Alerts in dictionary/object format - pwalerts = {} - alerts = pw.alerts() - if alerts is None: - message: Optional[str] = None - else: - for alert in alerts: - pwalerts[alert] = 1 - message: str = json.dumps(pwalerts) or json.dumps({}) - elif self.path == '/freq': - # Frequency, Current, Voltage and Grid Status - fcv = {} - # Pull freq, current, voltage of each Powerwall via system_status - d = pw.system_status() or {} - if "battery_blocks" in d: - for idx, block in enumerate(d["battery_blocks"], start=1): - fcv[f"PW{idx}_name"] = None # Placeholder for vitals - fcv[f"PW{idx}_PINV_Fout"] = get_value(block, "f_out") - fcv[f"PW{idx}_PINV_VSplit1"] = None # Placeholder for vitals - fcv[f"PW{idx}_PINV_VSplit2"] = None # Placeholder for vitals - fcv[f"PW{idx}_PackagePartNumber"] = get_value(block, "PackagePartNumber") - fcv[f"PW{idx}_PackageSerialNumber"] = get_value(block, "PackageSerialNumber") - fcv[f"PW{idx}_p_out"] = get_value(block, "p_out") - fcv[f"PW{idx}_q_out"] = get_value(block, "q_out") - fcv[f"PW{idx}_v_out"] = get_value(block, "v_out") - fcv[f"PW{idx}_f_out"] = get_value(block, "f_out") - fcv[f"PW{idx}_i_out"] = get_value(block, "i_out") - # Pull freq, current, voltage of each Powerwall via vitals if available - vitals = pw.vitals() or {} - for idx, device in enumerate(vitals, start=1): - d = vitals[device] - if device.startswith('TEPINV'): - # PW freq - fcv[f"PW{idx}_name"] = device - fcv[f"PW{idx}_PINV_Fout"] = get_value(d, 'PINV_Fout') - fcv[f"PW{idx}_PINV_VSplit1"] = get_value(d, 'PINV_VSplit1') - fcv[f"PW{idx}_PINV_VSplit2"] = get_value(d, 'PINV_VSplit2') - if device.startswith('TESYNC') or device.startswith('TEMSA'): - # Island and Meter Metrics from Backup Gateway or Backup Switch - for i in d: - if i.startswith('ISLAND') or i.startswith('METER'): - fcv[i] = d[i] - fcv["grid_status"] = pw.grid_status(type="numeric") - message: str = json.dumps(fcv) - elif self.path == '/pod': - # Powerwall Battery Data - pod = {} - # Get Individual Powerwall Battery Data - d = pw.system_status() or {} - if "battery_blocks" in d: - for idx, block in enumerate(d["battery_blocks"], start=1): - # Vital Placeholders - pod[f"PW{idx}_name"] = None - pod[f"PW{idx}_POD_ActiveHeating"] = None - pod[f"PW{idx}_POD_ChargeComplete"] = None - pod[f"PW{idx}_POD_ChargeRequest"] = None - pod[f"PW{idx}_POD_DischargeComplete"] = None - pod[f"PW{idx}_POD_PermanentlyFaulted"] = None - pod[f"PW{idx}_POD_PersistentlyFaulted"] = None - pod[f"PW{idx}_POD_enable_line"] = None - pod[f"PW{idx}_POD_available_charge_power"] = None - pod[f"PW{idx}_POD_available_dischg_power"] = None - pod[f"PW{idx}_POD_nom_energy_remaining"] = None - pod[f"PW{idx}_POD_nom_energy_to_be_charged"] = None - pod[f"PW{idx}_POD_nom_full_pack_energy"] = None - # Additional System Status Data - pod[f"PW{idx}_POD_nom_energy_remaining"] = get_value(block, "nominal_energy_remaining") # map - pod[f"PW{idx}_POD_nom_full_pack_energy"] = get_value(block, "nominal_full_pack_energy") # map - pod[f"PW{idx}_PackagePartNumber"] = get_value(block, "PackagePartNumber") - pod[f"PW{idx}_PackageSerialNumber"] = get_value(block, "PackageSerialNumber") - pod[f"PW{idx}_pinv_state"] = get_value(block, "pinv_state") - pod[f"PW{idx}_pinv_grid_state"] = get_value(block, "pinv_grid_state") - pod[f"PW{idx}_p_out"] = get_value(block, "p_out") - pod[f"PW{idx}_q_out"] = get_value(block, "q_out") - pod[f"PW{idx}_v_out"] = get_value(block, "v_out") - pod[f"PW{idx}_f_out"] = get_value(block, "f_out") - pod[f"PW{idx}_i_out"] = get_value(block, "i_out") - pod[f"PW{idx}_energy_charged"] = get_value(block, "energy_charged") - pod[f"PW{idx}_energy_discharged"] = get_value(block, "energy_discharged") - pod[f"PW{idx}_off_grid"] = int(get_value(block, "off_grid") or 0) - pod[f"PW{idx}_vf_mode"] = int(get_value(block, "vf_mode") or 0) - pod[f"PW{idx}_wobble_detected"] = int(get_value(block, "wobble_detected") or 0) - pod[f"PW{idx}_charge_power_clamped"] = int(get_value(block, "charge_power_clamped") or 0) - pod[f"PW{idx}_backup_ready"] = int(get_value(block, "backup_ready") or 0) - pod[f"PW{idx}_OpSeqState"] = get_value(block, "OpSeqState") - pod[f"PW{idx}_version"] = get_value(block, "version") - # Augment with Vitals Data if available - vitals = pw.vitals() or {} - for idx, device in enumerate(vitals, start=1): - if not device.startswith('TEPOD'): - continue - v = vitals[device] - pod[f"PW{idx}_name"] = device - pod[f"PW{idx}_POD_ActiveHeating"] = int(get_value(v, 'POD_ActiveHeating') or 0) - pod[f"PW{idx}_POD_ChargeComplete"] = int(get_value(v, 'POD_ChargeComplete') or 0) - pod[f"PW{idx}_POD_ChargeRequest"] = int(get_value(v, 'POD_ChargeRequest') or 0) - pod[f"PW{idx}_POD_DischargeComplete"] = int(get_value(v, 'POD_DischargeComplete') or 0) - pod[f"PW{idx}_POD_PermanentlyFaulted"] = int(get_value(v, 'POD_PermanentlyFaulted') or 0) - pod[f"PW{idx}_POD_PersistentlyFaulted"] = int(get_value(v, 'POD_PersistentlyFaulted') or 0) - pod[f"PW{idx}_POD_enable_line"] = int(get_value(v, 'POD_enable_line') or 0) - pod[f"PW{idx}_POD_available_charge_power"] = get_value(v, 'POD_available_charge_power') - pod[f"PW{idx}_POD_available_dischg_power"] = get_value(v, 'POD_available_dischg_power') - pod[f"PW{idx}_POD_nom_energy_remaining"] = get_value(v, 'POD_nom_energy_remaining') - pod[f"PW{idx}_POD_nom_energy_to_be_charged"] = get_value(v, 'POD_nom_energy_to_be_charged') - pod[f"PW{idx}_POD_nom_full_pack_energy"] = get_value(v, 'POD_nom_full_pack_energy') - # Aggregate data - 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 - version = pw.version() - v = {} - if version is None: - v["version"] = "SolarOnly" - v["vint"] = 0 - message: str = json.dumps(v) - else: - v["version"] = version - v["vint"] = parse_version(version) - message: str = json.dumps(v) + if 'load' in aggregates and 'instant_power' in aggregates['load']: + aggregates['load']['instant_power'] -= solar['instant_power'] + self.send_json_response(aggregates) + + + def handle_soe(self): + soe = pw.poll('/api/system_status/soe', jsonformat=True) + self.send_json_response(json.loads(soe)) + + def handle_soe(self): + soe = pw.poll('/api/system_status/soe', jsonformat=True) + self.send_json_response(json.loads(soe)) + + def handle_grid_status(self): + grid_status = pw.poll('/api/system_status/grid_status', jsonformat=True) + self.send_json_response(json.loads(grid_status)) + + def handle_csv(self): + # Grid,Home,Solar,Battery,Level - CSV + contenttype = 'text/plain; charset=utf-8' + batterylevel = pw.level() or 0 + grid = pw.grid() or 0 + solar = pw.solar() or 0 + battery = pw.battery() or 0 + home = pw.home() or 0 + if not CONFIG[CONFIG_TYPE.PW_NEG_SOLAR] and solar < 0: + solar = 0 + # Shift energy from solar to load + home -= solar + message = f"{grid:.2f},{home:.2f},{solar:.2f},{battery:.2f},{batterylevel:.2f}\n" + self.send_json_response(message) + + def handle_vitals(self): + vitals = pw.vitals(jsonformat=True) or {} + self.send_json_response(json.loads(vitals)) + + def handle_strings(self): + strings = pw.strings(jsonformat=True) or {} + self.send_json_response(json.loads(strings)) + + def handle_stats(self): + proxystats.update({ + PROXY_STATS_TYPE.TS: int(time.time()), + PROXY_STATS_TYPE.UPTIME: str(datetime.timedelta(seconds=(proxystats[PROXY_STATS_TYPE.TS] - proxystats[PROXY_STATS_TYPE.START]))), + PROXY_STATS_TYPE.MEM: resource.getrusage(resource.RUSAGE_SELF).ru_maxrss, + PROXY_STATS_TYPE.SITE_NAME: pw.site_name(), + PROXY_STATS_TYPE.CLOUDMODE: pw.cloudmode, + PROXY_STATS_TYPE.FLEETAPI: pw.fleetapi, + PROXY_STATS_TYPE.AUTH_MODE: pw.authmode + }) + if (pw.cloudmode or pw.fleetapi) and pw.client: + proxystats[PROXY_STATS_TYPE.SITEID] = pw.client.siteid + proxystats[PROXY_STATS_TYPE.COUNTER] = pw.client.counter + self.send_json_response(proxystats) + + def handle_stats_clear(self): + # Clear Internal Stats + log.debug("Clear internal stats") + proxystats.update({ + PROXY_STATS_TYPE.GETS: 0, + PROXY_STATS_TYPE.ERRORS: 0, + PROXY_STATS_TYPE.URI: {}, + PROXY_STATS_TYPE.CLEAR: int(time.time()), + }) + self.send_json_response(proxystats) + + def handle_temps(self): + temps = pw.temps(jsonformat=True) or {} + self.send_json_response(json.loads(temps)) + + def handle_temps_pw(self): + temps = pw.temps() or {} + pw_temp = {f"PW{idx}_temp": temp for idx, temp in enumerate(temps.values(), 1)} + self.send_json_response(pw_temp) + + def handle_alerts(self): + alerts = pw.alerts(jsonformat=True) or [] + self.send_json_response(alerts) + + def handle_alerts_pw(self): + alerts = pw.alerts() or [] + pw_alerts = {alert: 1 for alert in alerts} + self.send_json_response(pw_alerts) + + def handle_freq(self): + fcv = {} + system_status = pw.system_status() or {} + blocks = system_status.get("battery_blocks", []) + for idx, block in enumerate(blocks, 1): + fcv.update({ + f"PW{idx}_name": None, + f"PW{idx}_PINV_Fout": get_value(block, "f_out"), + f"PW{idx}_PINV_VSplit1": None, + f"PW{idx}_PINV_VSplit2": None, + f"PW{idx}_PackagePartNumber": get_value(block, "PackagePartNumber"), + f"PW{idx}_PackageSerialNumber": get_value(block, "PackageSerialNumber"), + f"PW{idx}_p_out": get_value(block, "p_out"), + f"PW{idx}_q_out": get_value(block, "q_out"), + f"PW{idx}_v_out": get_value(block, "v_out"), + f"PW{idx}_f_out": get_value(block, "f_out"), + f"PW{idx}_i_out": get_value(block, "i_out"), + }) + vitals = pw.vitals() or {} + for idx, (device, data) in enumerate(vitals.items()): + if device.startswith('TEPINV'): + fcv.update({ + f"PW{idx}_name": device, + f"PW{idx}_PINV_Fout": get_value(data, 'PINV_Fout'), + f"PW{idx}_PINV_VSplit1": get_value(data, 'PINV_VSplit1'), + f"PW{idx}_PINV_VSplit2": get_value(data, 'PINV_VSplit2') + }) + if device.startswith(('TESYNC', 'TEMSA')): + fcv.update({key: value for key, value in data.items() if key.startswith(('ISLAND', 'METER'))}) + fcv["grid_status"] = pw.grid_status(type="numeric") + self.send_json_response(fcv) + + def handle_pod(self): + # Powerwall Battery Data + pod = {} + # Get Individual Powerwall Battery Data + system_status = pw.system_status() or {} + blocks = system_status.get("battery_blocks", []) + for idx, block in enumerate(blocks, 1): + pod.update({ + # Vital Placeholders + f"PW{idx}_name": None, + f"PW{idx}_POD_ActiveHeating": None, + f"PW{idx}_POD_ChargeComplete": None, + f"PW{idx}_POD_ChargeRequest": None, + f"PW{idx}_POD_DischargeComplete": None, + f"PW{idx}_POD_PermanentlyFaulted": None, + f"PW{idx}_POD_PersistentlyFaulted": None, + f"PW{idx}_POD_enable_line": None, + f"PW{idx}_POD_available_charge_power": None, + f"PW{idx}_POD_available_dischg_power": None, + f"PW{idx}_POD_nom_energy_remaining": None, + f"PW{idx}_POD_nom_energy_to_be_charged": None, + f"PW{idx}_POD_nom_full_pack_energy": None, + # Additional System Status Data + f"PW{idx}_POD_nom_energy_remaining": get_value(block, "nominal_energy_remaining"), # map + f"PW{idx}_POD_nom_full_pack_energy": get_value(block, "nominal_full_pack_energy"), # map + f"PW{idx}_PackagePartNumber": get_value(block, "PackagePartNumber"), + f"PW{idx}_PackageSerialNumber": get_value(block, "PackageSerialNumber"), + f"PW{idx}_pinv_state": get_value(block, "pinv_state"), + f"PW{idx}_pinv_grid_state": get_value(block, "pinv_grid_state"), + f"PW{idx}_p_out": get_value(block, "p_out"), + f"PW{idx}_q_out": get_value(block, "q_out"), + f"PW{idx}_v_out": get_value(block, "v_out"), + f"PW{idx}_f_out": get_value(block, "f_out"), + f"PW{idx}_i_out": get_value(block, "i_out"), + f"PW{idx}_energy_charged": get_value(block, "energy_charged"), + f"PW{idx}_energy_discharged": get_value(block, "energy_discharged"), + f"PW{idx}_off_grid": int(get_value(block, "off_grid") or 0), + f"PW{idx}_vf_mode": int(get_value(block, "vf_mode") or 0), + f"PW{idx}_wobble_detected": int(get_value(block, "wobble_detected") or 0), + f"PW{idx}_charge_power_clamped": int(get_value(block, "charge_power_clamped") or 0), + f"PW{idx}_backup_ready": int(get_value(block, "backup_ready") or 0), + f"PW{idx}_OpSeqState": get_value(block, "OpSeqState"), + f"PW{idx}_version": get_value(block, "version") + }) + + vitals = pw.vitals() or {} + for idx, (device, data) in enumerate(vitals.items(), 1): + if not device.startswith('TEPOD'): + continue + pod.update({ + f"PW{idx}_name": device, + f"PW{idx}_POD_ActiveHeating": int(get_value(data, 'POD_ActiveHeating') or 0), + f"PW{idx}_POD_ChargeComplete": int(get_value(data, 'POD_ChargeComplete') or 0), + f"PW{idx}_POD_ChargeRequest": int(get_value(data, 'POD_ChargeRequest') or 0), + f"PW{idx}_POD_DischargeComplete": int(get_value(data, 'POD_DischargeComplete') or 0), + f"PW{idx}_POD_PermanentlyFaulted": int(get_value(data, 'POD_PermanentlyFaulted') or 0), + f"PW{idx}_POD_PersistentlyFaulted": int(get_value(data, 'POD_PersistentlyFaulted') or 0), + f"PW{idx}_POD_enable_line": int(get_value(data, 'POD_enable_line') or 0), + f"PW{idx}_POD_available_charge_power": get_value(data, 'POD_available_charge_power'), + f"PW{idx}_POD_available_dischg_power": get_value(data, 'POD_available_dischg_power'), + f"PW{idx}_POD_nom_energy_remaining": get_value(data, 'POD_nom_energy_remaining'), + f"PW{idx}_POD_nom_energy_to_be_charged": get_value(data, 'POD_nom_energy_to_be_charged'), + f"PW{idx}_POD_nom_full_pack_energy": get_value(data, 'POD_nom_full_pack_energy') + }) + + pod.update({ + "nominal_full_pack_energy": get_value(system_status, 'nominal_full_pack_energy'), + "nominal_energy_remaining": get_value(system_status, 'nominal_energy_remaining'), + "time_remaining_hours": pw.get_time_remaining(), + "backup_reserve_percent": pw.get_reserve() + }) + self.send_json_response(pod) + + def handle_version(self): + version = pw.version() + r = {"version": "SolarOnly", "vint": 0} if version is None else {"version": version, "vint": parse_version(version)} + self.send_json_response(r) + elif self.path == '/help': # Display friendly help screen link and stats proxystats[PROXY_STATS_TYPE.TS] = int(time.time()) @@ -891,27 +914,57 @@ def do_GET(self): except Exception as exc: log.debug(f"Socket broken sending API response to client [doGET]: {exc}") - -# noinspection PyTypeChecker -with ThreadingHTTPServer((CONFIG[CONFIG_TYPE.PW_BIND_ADDRESS], CONFIG[CONFIG_TYPE.PW_PORT]), Handler) as server: - if CONFIG[CONFIG_TYPE.PW_HTTPS] == "yes": - # Activate HTTPS - log.debug("Activating HTTPS") - # pylint: disable=deprecated-method - server.socket = ssl.wrap_socket( - server.socket, - certfile=os.path.join(os.path.dirname(__file__), 'localhost.pem'), - server_side=True, - ssl_version=ssl.PROTOCOL_TLSv1_2, - ca_certs=None, - do_handshake_on_connect=True +if __name__ == '__main__': + # Connect to Powerwall + # TODO: Add support for multiple Powerwalls + try: + pw = pypowerwall.Powerwall( + host=CONFIG[CONFIG_TYPE.PW_HOST], + password=CONFIG[CONFIG_TYPE.PW_PASSWORD], + email=CONFIG[CONFIG_TYPE.PW_EMAIL], + timezone=CONFIG[CONFIG_TYPE.PW_TIMEZONE], + cache_expire=CONFIG[CONFIG_TYPE.PW_CACHE_EXPIRE], + timeout=CONFIG[CONFIG_TYPE.PW_TIMEOUT], + pool_maxsize=CONFIG[CONFIG_TYPE.PW_POOL_MAXSIZE], + siteid=CONFIG[CONFIG_TYPE.PW_SITEID], + authpath=CONFIG[CONFIG_TYPE.PW_AUTH_PATH], + authmode=CONFIG[CONFIG_TYPE.PW_AUTH_MODE], + cachefile=CONFIG[CONFIG_TYPE.PW_CACHE_FILE], + auto_select=True, + retry_modes=True, + gw_pwd=CONFIG[CONFIG_TYPE.PW_GW_PWD] ) + except Exception as e: + log.error(e) + log.error("Fatal Error: Unable to connect. Please fix config and restart.") + while True: + try: + time.sleep(5) # Infinite loop to keep container running + except (KeyboardInterrupt, SystemExit): + sys.exit(0) + + pw_control = configure_pw_control(pw) + + # noinspection PyTypeChecker + with ThreadingHTTPServer((CONFIG[CONFIG_TYPE.PW_BIND_ADDRESS], CONFIG[CONFIG_TYPE.PW_PORT]), Handler) as server: + if CONFIG[CONFIG_TYPE.PW_HTTPS] == "yes": + # Activate HTTPS + log.debug("Activating HTTPS") + # pylint: disable=deprecated-method + server.socket = ssl.wrap_socket( + server.socket, + certfile=os.path.join(os.path.dirname(__file__), 'localhost.pem'), + server_side=True, + ssl_version=ssl.PROTOCOL_TLSv1_2, + ca_certs=None, + do_handshake_on_connect=True + ) - # noinspection PyBroadException - try: - server.serve_forever() - except (Exception, KeyboardInterrupt, SystemExit): - print(' CANCEL \n') + # noinspection PyBroadException + try: + server.serve_forever() + except (Exception, KeyboardInterrupt, SystemExit): + print(' CANCEL \n') - log.info("pyPowerwall Proxy Stopped") - sys.exit(0) + log.info("pyPowerwall Proxy Stopped") + sys.exit(0) From bd1fb0c9961900d670c73ec8c06fb6a60f4ed574 Mon Sep 17 00:00:00 2001 From: Christopher Pitstick Date: Thu, 9 Jan 2025 22:51:38 -0500 Subject: [PATCH 07/35] Continuing to refactor --- proxy/server.py | 634 +++++++++++++++++++++++++----------------------- 1 file changed, 337 insertions(+), 297 deletions(-) diff --git a/proxy/server.py b/proxy/server.py index 975f00d..4377a5c 100755 --- a/proxy/server.py +++ b/proxy/server.py @@ -93,8 +93,6 @@ ]) WEB_ROOT: Final[str] = os.path.join(os.path.dirname(__file__), "web") - - # bind_address = os.getenv("PW_BIND_ADDRESS", "") # password = os.getenv("PW_PASSWORD", "") # email = os.getenv("PW_EMAIL", "email@example.com") @@ -207,7 +205,6 @@ def sig_term_handle(signum, frame): signal.signal(signal.SIGTERM, sig_term_handle) - class PROXY_STATS_TYPE(StrEnum): """_summary_ @@ -309,7 +306,7 @@ def get_value(a, key): proxystats[PROXY_STATS_TYPE.PW3] = pw.tedapi.pw3 log.info(f"TEDAPI Mode Enabled for Device Vitals ({pw.tedapi_mode})") -def configure_pw_control(pw): +def configure_pw_control(pw: pypowerwall.Powerwall) -> pypowerwall.Powerwall: if not CONFIG[CONFIG_TYPE.PW_CONTROL_SECRET]: return None @@ -329,7 +326,7 @@ def configure_pw_control(pw): auto_select=True ) except Exception as e: - log.error("Control Mode Failed: Unable to connect to cloud - Run Setup") + log.error(f"Control Mode Failed {e}: Unable to connect to cloud - Run Setup") CONFIG[CONFIG_TYPE.PW_CONTROL_SECRET] = None return None @@ -342,9 +339,6 @@ def configure_pw_control(pw): return pw_control -class ThreadingHTTPServer(ThreadingMixIn, HTTPServer): - daemon_threads = True - # pylint: disable=arguments-differ,global-variable-not-assigned # noinspection PyPep8Naming class Handler(BaseHTTPRequestHandler): @@ -368,70 +362,86 @@ def send_json_response(self, data, status_code=HTTPStatus.OK, content_type='appl self.wfile.write(response.encode("utf8")) except Exception as exc: log.debug("Error sending response: %s", exc) + + def handle_control_post(self) -> bool: + """Handle control POST requests.""" + if not pw_control: + proxystats[PROXY_STATS_TYPE.ERRORS] += 1 + self.send_json_response( + {"error": "Control Commands Disabled - Set PW_CONTROL_SECRET to enable"}, + status_code=HTTPStatus.BAD_REQUEST + ) + return False + + try: + action = urlparse(self.path).path.split('/')[2] + content_length = int(self.headers.get('Content-Length', 0)) + post_data = self.rfile.read(content_length) + query_params = parse_qs(post_data.decode('utf-8')) + value = query_params.get('value', [''])[0] + token = query_params.get('token', [''])[0] + except Exception as er: + log.error("Control Command Error: %s", er) + self.send_json_response( + {"error": "Control Command Error: Invalid Request"}, + status_code=HTTPStatus.BAD_REQUEST + ) + return False + + if token != CONFIG['PW_CONTROL_SECRET']: + self.send_json_response( + {"unauthorized": "Control Command Token Invalid"}, + status_code=HTTPStatus.UNAUTHORIZED + ) + return False + + if action == 'reserve': + if not value: + self.send_json_response({"reserve": pw_control.get_reserve()}) + return True + elif value.isdigit(): + result = pw_control.set_reserve(int(value)) + log.info(f"Control Command: Set Reserve to {value}") + self.send_json_response(result) + return True + else: + self.send_json_response( + {"error": "Control Command Value Invalid"}, + status_code=HTTPStatus.BAD_REQUEST + ) + elif action == 'mode': + if not value: + self.send_json_response({"mode": pw_control.get_mode()}) + return True + elif value in ['self_consumption', 'backup', 'autonomous']: + result = pw_control.set_mode(value) + log.info(f"Control Command: Set Mode to {value}") + self.send_json_response(result) + return True + else: + self.send_json_response( + {"error": "Control Command Value Invalid"}, + status_code=HTTPStatus.BAD_REQUEST + ) + else: + self.send_json_response( + {"error": "Invalid Command Action"}, + status_code=HTTPStatus.BAD_REQUEST + ) + return False + def do_POST(self): - global proxystats - contenttype = 'application/json' - message = '{"error": "Invalid Request"}' + """Handle POST requests.""" if self.path.startswith('/control'): - # curl -X POST -d "value=20&token=1234" http://localhost:8675/control/reserve - # curl -X POST -d "value=backup&token=1234" http://localhost:8675/control/mode - message = None - if not CONFIG[CONFIG_TYPE.PW_CONTROL_SECRET]: - message = '{"error": "Control Commands Disabled - Set PW_CONTROL_SECRET to enable"}' - else: - try: - action = urlparse(self.path).path.split('/')[2] - post_data = self.rfile.read(int(self.headers['Content-Length'])) - query_params = parse_qs(post_data.decode('utf-8')) - value = query_params.get('value', [''])[0] - token = query_params.get('token', [''])[0] - except Exception as er: - message = '{"error": "Control Command Error: Invalid Request"}' - log.error(f"Control Command Error: {er}") - 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 == CONFIG[CONFIG_TYPE.PW_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() - elif 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() - elif 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": "Invalid Command Action"}' - else: - message = '{"unauthorized": "Control Command Token Invalid"}' - if "error" in message: - self.send_response(HTTPStatus.BAD_REQUEST) - proxystats[PROXY_STATS_TYPE.ERRORS] += 1 - elif "unauthorized" in message: - self.send_response(HTTPStatus.UNAUTHORIZED) + stat = PROXY_STATS_TYPE.POSTS if self.handle_control_post() else PROXY_STATS_TYPE.ERRORS + proxystats[stat] += 1 else: - self.send_response(HTTPStatus.OK) - proxystats[PROXY_STATS_TYPE.POSTS] += 1 - self.send_header('Content-type', contenttype) - self.send_header('Content-Length', str(len(message))) - self.send_header("Access-Control-Allow-Origin", "*") - self.end_headers() - self.wfile.write(message.encode("utf8")) + self.send_json_response( + {"error": "Invalid Request"}, + status_code=HTTPStatus.BAD_REQUEST + ) + def do_GET(self): """Handle GET requests.""" @@ -477,6 +487,20 @@ def do_GET(self): self.handle_cloud(path) elif path.startswith('/fleetapi'): self.handle_fleetapi(path) + elif self.path.startswith('/control/reserve'): + # Current battery reserve level + if not pw_control: + message = '{"error": "Control Commands Disabled - Set PW_CONTROL_SECRET to enable"}' + else: + message = '{"reserve": %s}' % pw_control.get_reserve() + self.send_json_response(json.loads(message)) + elif self.path.startswith('/control/mode'): + # Current operating mode + if not pw_control: + message = '{"error": "Control Commands Disabled - Set PW_CONTROL_SECRET to enable"}' + else: + message = '{"mode": "%s"}' % pw_control.get_mode() + self.send_json_response(json.loads(message)) else: self.handle_static_content() @@ -496,15 +520,18 @@ def handle_aggregates(self): def handle_soe(self): soe = pw.poll('/api/system_status/soe', jsonformat=True) self.send_json_response(json.loads(soe)) - + + def handle_soe(self): soe = pw.poll('/api/system_status/soe', jsonformat=True) self.send_json_response(json.loads(soe)) + def handle_grid_status(self): grid_status = pw.poll('/api/system_status/grid_status', jsonformat=True) self.send_json_response(json.loads(grid_status)) + def handle_csv(self): # Grid,Home,Solar,Battery,Level - CSV contenttype = 'text/plain; charset=utf-8' @@ -519,15 +546,18 @@ def handle_csv(self): home -= solar message = f"{grid:.2f},{home:.2f},{solar:.2f},{battery:.2f},{batterylevel:.2f}\n" self.send_json_response(message) - + + def handle_vitals(self): vitals = pw.vitals(jsonformat=True) or {} self.send_json_response(json.loads(vitals)) + def handle_strings(self): strings = pw.strings(jsonformat=True) or {} self.send_json_response(json.loads(strings)) - + + def handle_stats(self): proxystats.update({ PROXY_STATS_TYPE.TS: int(time.time()), @@ -543,6 +573,7 @@ def handle_stats(self): proxystats[PROXY_STATS_TYPE.COUNTER] = pw.client.counter self.send_json_response(proxystats) + def handle_stats_clear(self): # Clear Internal Stats log.debug("Clear internal stats") @@ -554,24 +585,29 @@ def handle_stats_clear(self): }) self.send_json_response(proxystats) + def handle_temps(self): temps = pw.temps(jsonformat=True) or {} self.send_json_response(json.loads(temps)) - + + def handle_temps_pw(self): temps = pw.temps() or {} pw_temp = {f"PW{idx}_temp": temp for idx, temp in enumerate(temps.values(), 1)} self.send_json_response(pw_temp) - + + def handle_alerts(self): alerts = pw.alerts(jsonformat=True) or [] self.send_json_response(alerts) + def handle_alerts_pw(self): alerts = pw.alerts() or [] pw_alerts = {alert: 1 for alert in alerts} self.send_json_response(pw_alerts) - + + def handle_freq(self): fcv = {} system_status = pw.system_status() or {} @@ -604,6 +640,7 @@ def handle_freq(self): fcv["grid_status"] = pw.grid_status(type="numeric") self.send_json_response(fcv) + def handle_pod(self): # Powerwall Battery Data pod = {} @@ -677,242 +714,245 @@ def handle_pod(self): }) self.send_json_response(pod) + def handle_version(self): version = pw.version() r = {"version": "SolarOnly", "vint": 0} if version is None else {"version": version, "vint": parse_version(version)} self.send_json_response(r) - elif self.path == '/help': - # Display friendly help screen link and stats - proxystats[PROXY_STATS_TYPE.TS] = int(time.time()) - delta = proxystats[PROXY_STATS_TYPE.TS] - proxystats[PROXY_STATS_TYPE.START] - proxystats[PROXY_STATS_TYPE.UPTIME] = str(datetime.timedelta(seconds=delta)) - proxystats[PROXY_STATS_TYPE.MEM] = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss - proxystats[PROXY_STATS_TYPE.SITE_NAME] = pw.site_name() - proxystats[PROXY_STATS_TYPE.CLOUDMODE] = pw.cloudmode - proxystats[PROXY_STATS_TYPE.FLEETAPI] = pw.fleetapi - if (pw.cloudmode or pw.fleetapi) and pw.client is not None: - proxystats[PROXY_STATS_TYPE.SITEID] = pw.client.siteid - proxystats[PROXY_STATS_TYPE.COUNTER] = pw.client.counter - proxystats[PROXY_STATS_TYPE.AUTH_MODE] = pw.authmode - contenttype = 'text/html' - message: str = """ - \n\n - \n - \n - \n\n

pyPowerwall [%VER%] Proxy [%BUILD%]

\n\n -

- Click here for API help.

\n\n - \n - """ - message = message.replace('%VER%', pypowerwall.version).replace('%BUILD%', BUILD) - for i in proxystats: - if i != PROXY_STATS_TYPE.URI and i != PROXY_STATS_TYPE.CONFIG: - message += f'\n' - for i in proxystats[PROXY_STATS_TYPE.URI]: - message += f'\n' - message += """ - - - - - - """ - message += "
StatValue
{i}{proxystats[i]}
URI: {i}{proxystats[PROXY_STATS_TYPE.URI][i]}
Config: -
- Click to view - - """ - for i in proxystats[PROXY_STATS_TYPE.CONFIG]: - message += f'\n' - message += """ -
{i}{proxystats[PROXY_STATS_TYPE.CONFIG][i]}
-
-
\n" - message += f'\n

Page refresh: {str(datetime.datetime.fromtimestamp(time.time()))}

\n\n' - elif self.path == '/api/troubleshooting/problems': - # Simulate old API call and respond with empty list - message = '{"problems": []}' - # message = pw.poll('/api/troubleshooting/problems') or '{"problems": []}' - elif self.path.startswith('/tedapi'): - # TEDAPI Specific Calls - if pw.tedapi: - message = '{"error": "Use /tedapi/config, /tedapi/status, /tedapi/components, /tedapi/battery, /tedapi/controller"}' - if self.path == '/tedapi/config': - message = json.dumps(pw.tedapi.get_config()) - if self.path == '/tedapi/status': - message = json.dumps(pw.tedapi.get_status()) - if self.path == '/tedapi/components': - message = json.dumps(pw.tedapi.get_components()) - if self.path == '/tedapi/battery': - message = json.dumps(pw.tedapi.get_battery_blocks()) - if self.path == '/tedapi/controller': - message = json.dumps(pw.tedapi.get_device_controller()) - else: - message = '{"error": "TEDAPI not enabled"}' - elif self.path.startswith('/cloud'): - # Cloud API Specific Calls - if pw.cloudmode and not pw.fleetapi: - message = '{"error": "Use /cloud/battery, /cloud/power, /cloud/config"}' - if self.path == '/cloud/battery': - message = json.dumps(pw.client.get_battery()) - if self.path == '/cloud/power': - message = json.dumps(pw.client.get_site_power()) - if self.path == '/cloud/config': - message = json.dumps(pw.client.get_site_config()) - else: - message = '{"error": "Cloud API not enabled"}' - elif self.path.startswith('/fleetapi'): - # FleetAPI Specific Calls - if pw.fleetapi: - message = '{"error": "Use /fleetapi/info, /fleetapi/status"}' - if self.path == '/fleetapi/info': - message = json.dumps(pw.client.get_site_info()) - if self.path == '/fleetapi/status': - message = json.dumps(pw.client.get_live_status()) - else: - message = '{"error": "FleetAPI not enabled"}' - elif self.path in DISABLED: - # Disabled API Calls - message = '{"status": "404 Response - API Disabled"}' - elif self.path in ALLOWLIST: - # Allowed API Calls - Proxy to Powerwall - message: str = pw.poll(self.path, jsonformat=True) - elif self.path.startswith('/control/reserve'): - # Current battery reserve level - if not pw_control: - message = '{"error": "Control Commands Disabled - Set PW_CONTROL_SECRET to enable"}' - else: - message = '{"reserve": %s}' % pw_control.get_reserve() - elif self.path.startswith('/control/mode'): - # Current operating mode - if not pw_control: - message = '{"error": "Control Commands Disabled - Set PW_CONTROL_SECRET to enable"}' - else: - message = '{"mode": "%s"}' % pw_control.get_mode() + }); + + """ + message += "\n" + message += f'\n

Page refresh: {str(datetime.datetime.fromtimestamp(time.time()))}

\n\n' + + def handle_problems(self): + self.send_json_response({"problems": []}) + + + def handle_tedapi(self, path): + if not pw.tedapi: + self.send_json_response({"error": "TEDAPI not enabled"}, status_code=HTTPStatus.BAD_REQUEST) + return + + commands = { + '/tedapi/config': pw.tedapi.get_config, + '/tedapi/status': pw.tedapi.get_status, + '/tedapi/components': pw.tedapi.get_components, + '/tedapi/battery': pw.tedapi.get_battery_blocks, + '/tedapi/controller': pw.tedapi.get_device_controller, + } + command = commands.get(path) + if command: + self.send_json_response(command()) else: - # Everything else - Set auth headers required for web application - proxystats[PROXY_STATS_TYPE.GETS] += 1 - if pw.authmode == "token": - # Create bogus cookies - self.send_header("Set-Cookie", f"AuthCookie=1234567890;{CONFIG[CONFIG_TYPE.PW_COOKIE_SUFFIX]}") - self.send_header("Set-Cookie", f"UserRecord=1234567890;{CONFIG[CONFIG_TYPE.PW_COOKIE_SUFFIX]}") - else: - self.send_header("Set-Cookie", f"AuthCookie={pw.client.auth['AuthCookie']};{CONFIG[CONFIG_TYPE.PW_COOKIE_SUFFIX]}") - self.send_header("Set-Cookie", f"UserRecord={pw.client.auth['UserRecord']};{CONFIG[CONFIG_TYPE.PW_COOKIE_SUFFIX]}") - - # Serve static assets from web root first, if found. - # pylint: disable=attribute-defined-outside-init - if self.path == "/" or self.path == "": - self.path = "/index.html" - fcontent, ftype = get_static(WEB_ROOT, self.path) - # Replace {VARS} with current data - status = pw.status() - # convert fcontent to string - fcontent = fcontent.decode("utf-8") - # fix the following variables that if they are None, return "" - fcontent = fcontent.replace("{VERSION}", status["version"] or "") - fcontent = fcontent.replace("{HASH}", status["git_hash"] or "") - fcontent = fcontent.replace("{EMAIL}", CONFIG[CONFIG_TYPE.PW_EMAIL]) - fcontent = fcontent.replace("{STYLE}", CONFIG[CONFIG_TYPE.PW_STYLE]) - # convert fcontent back to bytes - fcontent = bytes(fcontent, 'utf-8') - else: - fcontent, ftype = get_static(WEB_ROOT, self.path) - if fcontent: - log.debug("Served from local web root: {} type {}".format(self.path, ftype)) - # If not found, serve from Powerwall web server - elif pw.cloudmode or pw.fleetapi: - log.debug("Cloud Mode - File not found: {}".format(self.path)) - fcontent = bytes("Not Found", 'utf-8') - ftype = "text/plain" - else: - # Proxy request to Powerwall web server. - proxy_path = self.path - if proxy_path.startswith("/"): - proxy_path = proxy_path[1:] - pw_url = f"https://{pw.host}/{proxy_path}" - log.debug(f"Proxy request to: {pw_url}") - try: - if pw.authmode == "token": - r = pw.client.session.get( - url=pw_url, - headers=pw.auth, - verify=False, - stream=True, - timeout=pw.timeout - ) - else: - r = pw.client.session.get( - url=pw_url, - cookies=pw.auth, - verify=False, - stream=True, - timeout=pw.timeout - ) - fcontent = r.content - ftype = r.headers['content-type'] - except AttributeError: - # Display 404 - log.debug("File not found: {}".format(self.path)) - fcontent = bytes("Not Found", 'utf-8') - ftype = "text/plain" - - # Allow browser caching, if user permits, only for CSS, JavaScript and PNG images... - if CONFIG[CONFIG_TYPE.PW_BROWSER_CACHE] > 0 and (ftype == 'text/css' or ftype == 'application/javascript' or ftype == 'image/png'): - self.send_header("Cache-Control", "max-age={}".format(CONFIG[CONFIG_TYPE.PW_BROWSER_CACHE])) - else: - self.send_header("Cache-Control", "no-cache, no-store") + self.send_json_response( + {"error": "Use /tedapi/config, /tedapi/status, /tedapi/components, /tedapi/battery, /tedapi/controller"}, + status_code=HTTPStatus.BAD_REQUEST + ) - # Inject transformations - if self.path.split('?')[0] == "/": - if os.path.exists(os.path.join(WEB_ROOT, CONFIG[CONFIG_TYPE.PW_STYLE])): - fcontent = bytes(inject_js(fcontent, CONFIG[CONFIG_TYPE.PW_STYLE]), 'utf-8') - self.send_header('Content-type', '{}'.format(ftype)) - self.end_headers() - try: - self.wfile.write(fcontent) - except Exception as exc: - if "Broken pipe" in str(exc): - log.debug(f"Client disconnected before payload sent [doGET]: {exc}") - return - log.error(f"Error occured while sending PROXY response to client [doGET]: {exc}") + def handle_cloud(self, path): + if not pw.cloudmode or pw.fleetapi: + self.send_json_response({"error": "Cloud API not enabled"}, status_code=HTTPStatus.BAD_REQUEST) return - # Count - if message is None: - proxystats[PROXY_STATS_TYPE.TIMEOUT] += 1 - message = "TIMEOUT!" - elif message == "ERROR!": - proxystats[PROXY_STATS_TYPE.ERRORS] += 1 - message = "ERROR!" + commands = { + '/cloud/battery': pw.client.get_battery, + '/cloud/power': pw.client.get_site_power, + '/cloud/config': pw.client.get_site_config, + } + command = commands.get(path) + if command: + self.send_json_response(command()) else: - proxystats[PROXY_STATS_TYPE.GETS] += 1 - if self.path in proxystats[PROXY_STATS_TYPE.URI]: - proxystats[PROXY_STATS_TYPE.URI][self.path] += 1 - else: - proxystats[PROXY_STATS_TYPE.URI][self.path] = 1 + self.send_json_response({"error": "Use /cloud/battery, /cloud/power, /cloud/config"}, status_code=HTTPStatus.BAD_REQUEST) - # Send headers and payload - try: - self.send_header('Content-type', contenttype) - self.send_header('Content-Length', str(len(message))) - self.send_header("Access-Control-Allow-Origin", "*") - self.end_headers() - self.wfile.write(message.encode("utf8")) - except Exception as exc: - log.debug(f"Socket broken sending API response to client [doGET]: {exc}") + + def handle_fleetapi(self, path): + if not pw.fleetapi: + self.send_json_response({"error": "FleetAPI not enabled"}, status_code=HTTPStatus.BAD_REQUEST) + return + + commands = { + '/fleetapi/info': pw.client.get_site_info, + '/fleetapi/status': pw.client.get_live_status, + } + command = commands.get(path) + if command: + self.send_json_response(command()) + else: + self.send_json_response({"error": "Use /fleetapi/info, /fleetapi/status"}, status_code=HTTPStatus.BAD_REQUEST) + + # else: + # # Everything else - Set auth headers required for web application + # proxystats[PROXY_STATS_TYPE.GETS] += 1 + # if pw.authmode == "token": + # # Create bogus cookies + # self.send_header("Set-Cookie", f"AuthCookie=1234567890;{CONFIG[CONFIG_TYPE.PW_COOKIE_SUFFIX]}") + # self.send_header("Set-Cookie", f"UserRecord=1234567890;{CONFIG[CONFIG_TYPE.PW_COOKIE_SUFFIX]}") + # else: + # self.send_header("Set-Cookie", f"AuthCookie={pw.client.auth['AuthCookie']};{CONFIG[CONFIG_TYPE.PW_COOKIE_SUFFIX]}") + # self.send_header("Set-Cookie", f"UserRecord={pw.client.auth['UserRecord']};{CONFIG[CONFIG_TYPE.PW_COOKIE_SUFFIX]}") + + # # Serve static assets from web root first, if found. + # # pylint: disable=attribute-defined-outside-init + # if self.path == "/" or self.path == "": + # self.path = "/index.html" + # fcontent, ftype = get_static(WEB_ROOT, self.path) + # # Replace {VARS} with current data + # status = pw.status() + # # convert fcontent to string + # fcontent = fcontent.decode("utf-8") + # # fix the following variables that if they are None, return "" + # fcontent = fcontent.replace("{VERSION}", status["version"] or "") + # fcontent = fcontent.replace("{HASH}", status["git_hash"] or "") + # fcontent = fcontent.replace("{EMAIL}", CONFIG[CONFIG_TYPE.PW_EMAIL]) + # fcontent = fcontent.replace("{STYLE}", CONFIG[CONFIG_TYPE.PW_STYLE]) + # # convert fcontent back to bytes + # fcontent = bytes(fcontent, 'utf-8') + # else: + # fcontent, ftype = get_static(WEB_ROOT, self.path) + # if fcontent: + # log.debug("Served from local web root: {} type {}".format(self.path, ftype)) + # # If not found, serve from Powerwall web server + # elif pw.cloudmode or pw.fleetapi: + # log.debug("Cloud Mode - File not found: {}".format(self.path)) + # fcontent = bytes("Not Found", 'utf-8') + # ftype = "text/plain" + # else: + # # Proxy request to Powerwall web server. + # proxy_path = self.path + # if proxy_path.startswith("/"): + # proxy_path = proxy_path[1:] + # pw_url = f"https://{pw.host}/{proxy_path}" + # log.debug(f"Proxy request to: {pw_url}") + # try: + # if pw.authmode == "token": + # r = pw.client.session.get( + # url=pw_url, + # headers=pw.auth, + # verify=False, + # stream=True, + # timeout=pw.timeout + # ) + # else: + # r = pw.client.session.get( + # url=pw_url, + # cookies=pw.auth, + # verify=False, + # stream=True, + # timeout=pw.timeout + # ) + # fcontent = r.content + # ftype = r.headers['content-type'] + # except AttributeError: + # # Display 404 + # log.debug("File not found: {}".format(self.path)) + # fcontent = bytes("Not Found", 'utf-8') + # ftype = "text/plain" + + # # Allow browser caching, if user permits, only for CSS, JavaScript and PNG images... + # if CONFIG[CONFIG_TYPE.PW_BROWSER_CACHE] > 0 and (ftype == 'text/css' or ftype == 'application/javascript' or ftype == 'image/png'): + # self.send_header("Cache-Control", "max-age={}".format(CONFIG[CONFIG_TYPE.PW_BROWSER_CACHE])) + # else: + # self.send_header("Cache-Control", "no-cache, no-store") + + # # Inject transformations + # if self.path.split('?')[0] == "/": + # if os.path.exists(os.path.join(WEB_ROOT, CONFIG[CONFIG_TYPE.PW_STYLE])): + # fcontent = bytes(inject_js(fcontent, CONFIG[CONFIG_TYPE.PW_STYLE]), 'utf-8') + + # self.send_header('Content-type', '{}'.format(ftype)) + # self.end_headers() + # try: + # self.wfile.write(fcontent) + # except Exception as exc: + # if "Broken pipe" in str(exc): + # log.debug(f"Client disconnected before payload sent [doGET]: {exc}") + # return + # log.error(f"Error occured while sending PROXY response to client [doGET]: {exc}") + # return + + # # Count + # if message is None: + # proxystats[PROXY_STATS_TYPE.TIMEOUT] += 1 + # message = "TIMEOUT!" + # elif message == "ERROR!": + # proxystats[PROXY_STATS_TYPE.ERRORS] += 1 + # message = "ERROR!" + # else: + # proxystats[PROXY_STATS_TYPE.GETS] += 1 + # if self.path in proxystats[PROXY_STATS_TYPE.URI]: + # proxystats[PROXY_STATS_TYPE.URI][self.path] += 1 + # else: + # proxystats[PROXY_STATS_TYPE.URI][self.path] = 1 + + # # Send headers and payload + # try: + # self.send_header('Content-type', contenttype) + # self.send_header('Content-Length', str(len(message))) + # self.send_header("Access-Control-Allow-Origin", "*") + # self.end_headers() + # self.wfile.write(message.encode("utf8")) + # except Exception as exc: + # log.debug(f"Socket broken sending API response to client [doGET]: {exc}") + +class ThreadingHTTPServer(ThreadingMixIn, HTTPServer): + daemon_threads = True if __name__ == '__main__': # Connect to Powerwall From ce4b80d560dd51cb656fcbe3e864d16859bf122e Mon Sep 17 00:00:00 2001 From: Christopher Pitstick Date: Thu, 9 Jan 2025 23:30:36 -0500 Subject: [PATCH 08/35] Nearly done with initial refactor --- proxy/server.py | 74 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/proxy/server.py b/proxy/server.py index 4377a5c..78e8f5f 100755 --- a/proxy/server.py +++ b/proxy/server.py @@ -837,6 +837,80 @@ def handle_fleetapi(self, path): else: self.send_json_response({"error": "Use /fleetapi/info, /fleetapi/status"}, status_code=HTTPStatus.BAD_REQUEST) + + def handle_static_content(self): + proxystats[PROXY_STATS_TYPE.GETS] += 1 + self.send_response(HTTPStatus.OK) + self.send_header('Content-type', 'text/html') + if pw.authmode == "token": + self.send_header("Set-Cookie", f"AuthCookie=1234567890;{CONFIG[CONFIG_TYPE.PW_COOKIE_SUFFIX]}") + self.send_header("Set-Cookie", f"UserRecord=1234567890;{CONFIG[CONFIG_TYPE.PW_COOKIE_SUFFIX]}") + else: + auth = pw.client.auth + self.send_header("Set-Cookie", f"AuthCookie={auth['AuthCookie']};{CONFIG[CONFIG_TYPE.PW_COOKIE_SUFFIX]}") + self.send_header("Set-Cookie", f"UserRecord={auth['UserRecord']};{CONFIG[CONFIG_TYPE.PW_COOKIE_SUFFIX]}") + + if self.path == "/" or self.path == "": + self.path = "/index.html" + content, content_type = get_static(WEB_ROOT, self.path) + status = pw.status() + content = content.decode("utf-8").format( + VERSION=status.get("version", ""), + HASH=status.get("git_hash", ""), + EMAIL=CONFIG['PW_EMAIL'], + STYLE=CONFIG['PW_STYLE'], + ).encode('utf-8') + else: + content, content_type = get_static(WEB_ROOT, self.path) + + if content: + log.debug("Served from local web root: {} type {}".format(self.path, content_type)) + # If not found, serve from Powerwall web server + elif pw.cloudmode or pw.fleetapi: + log.debug(f"Cloud Mode - File not found: {self.path}") + content = b"Not Found" + content_type = "text/plain" + else: + # Proxy request to Powerwall web server. + pw_url = f"https://{pw.host}/{self.path.lstrip('/')}" + log.debug("Proxy request to: %s", pw_url) + try: + session = pw.client.session + response = session.get( + url=pw_url, + headers=pw.auth if pw.authmode == "token" else None, + cookies=None if pw.authmode == "token" else pw.auth, + verify=False, + stream=True, + timeout=pw.timeout, + ) + content = response.content + content_type = response.headers.get('content-type', 'text/html') + except Exception as exc: + log.error("Error proxying request: %s", exc) + content = b"Error during proxy" + content_type = "text/plain" + + if CONFIG['PW_BROWSER_CACHE'] > 0 and content_type in ['text/css', 'application/javascript', 'image/png']: + self.send_header("Cache-Control", f"max-age={CONFIG[CONFIG_TYPE.PW_BROWSER_CACHE]}") + else: + self.send_header("Cache-Control", "no-cache, no-store") + + if self.path.split('?')[0] == "/": + if os.path.exists(os.path.join(WEB_ROOT, CONFIG[CONFIG_TYPE.PW_STYLE])): + content = bytes(inject_js(content, CONFIG[CONFIG_TYPE.PW_STYLE]), 'utf-8') + + self.send_header('Content-type', content_type) + self.end_headers() + try: + self.wfile.write(content) + except Exception as exc: + if "Broken pipe" in str(exc): + log.debug(f"Client disconnected before payload sent [doGET]: {exc}") + return + log.error(f"Error occured while sending PROXY response to client [doGET]: {exc}") + + # else: # # Everything else - Set auth headers required for web application # proxystats[PROXY_STATS_TYPE.GETS] += 1 From 5ddf1d9089018b18e0439c47c50a0734f21ed174 Mon Sep 17 00:00:00 2001 From: Christopher Pitstick Date: Thu, 9 Jan 2025 23:31:41 -0500 Subject: [PATCH 09/35] More refactoring --- proxy/server.py | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/proxy/server.py b/proxy/server.py index 78e8f5f..4a9f0cc 100755 --- a/proxy/server.py +++ b/proxy/server.py @@ -504,6 +504,20 @@ def do_GET(self): else: self.handle_static_content() + # # Count + # if message is None: + # proxystats[PROXY_STATS_TYPE.TIMEOUT] += 1 + # message = "TIMEOUT!" + # elif message == "ERROR!": + # proxystats[PROXY_STATS_TYPE.ERRORS] += 1 + # message = "ERROR!" + # else: + # proxystats[PROXY_STATS_TYPE.GETS] += 1 + # if self.path in proxystats[PROXY_STATS_TYPE.URI]: + # proxystats[PROXY_STATS_TYPE.URI][self.path] += 1 + # else: + # proxystats[PROXY_STATS_TYPE.URI][self.path] = 1 + def handle_aggregates(self): # Meters - JSON aggregates = pw.poll('/api/meters/aggregates') @@ -1001,19 +1015,7 @@ def handle_static_content(self): # log.error(f"Error occured while sending PROXY response to client [doGET]: {exc}") # return - # # Count - # if message is None: - # proxystats[PROXY_STATS_TYPE.TIMEOUT] += 1 - # message = "TIMEOUT!" - # elif message == "ERROR!": - # proxystats[PROXY_STATS_TYPE.ERRORS] += 1 - # message = "ERROR!" - # else: - # proxystats[PROXY_STATS_TYPE.GETS] += 1 - # if self.path in proxystats[PROXY_STATS_TYPE.URI]: - # proxystats[PROXY_STATS_TYPE.URI][self.path] += 1 - # else: - # proxystats[PROXY_STATS_TYPE.URI][self.path] = 1 + # # Send headers and payload # try: From 5c7b27bf8963d8b6b7019905ff0dc789763af6ec Mon Sep 17 00:00:00 2001 From: Christopher Pitstick Date: Fri, 10 Jan 2025 22:49:14 -0500 Subject: [PATCH 10/35] More refactoring --- proxy/Dockerfile | 2 +- proxy/server.py | 437 ++++++++++++++++++++--------------------------- 2 files changed, 185 insertions(+), 254 deletions(-) diff --git a/proxy/Dockerfile b/proxy/Dockerfile index c98c7df..6055ea3 100644 --- a/proxy/Dockerfile +++ b/proxy/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.10-alpine +FROM python:3.12-alpine WORKDIR /app COPY requirements.txt /app/requirements.txt RUN pip3 install -r requirements.txt diff --git a/proxy/server.py b/proxy/server.py index 4a9f0cc..744247f 100755 --- a/proxy/server.py +++ b/proxy/server.py @@ -45,11 +45,11 @@ import ssl import sys import time +import threading from enum import StrEnum, auto from http import HTTPStatus -from http.server import BaseHTTPRequestHandler, HTTPServer -from socketserver import ThreadingMixIn -from typing import Any, Dict, Final, Optional, Set +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any, Dict, Final, Set, List from urllib.parse import parse_qs, urlparse from transform import get_static, inject_js @@ -93,30 +93,6 @@ ]) WEB_ROOT: Final[str] = os.path.join(os.path.dirname(__file__), "web") - # bind_address = os.getenv("PW_BIND_ADDRESS", "") - # password = os.getenv("PW_PASSWORD", "") - # email = os.getenv("PW_EMAIL", "email@example.com") - # host = os.getenv("PW_HOST", "") - # timezone = os.getenv("PW_TIMEZONE", "America/Los_Angeles") - # debugmode = os.getenv("PW_DEBUG", "no").lower() == "yes" - # cache_expire = int(os.getenv("PW_CACHE_EXPIRE", "5")) - # browser_cache = int(os.getenv("PW_BROWSER_CACHE", "0")) - # timeout = int(os.getenv("PW_TIMEOUT", "5")) - # pool_maxsize = int(os.getenv("PW_POOL_MAXSIZE", "15")) - # https_mode = os.getenv("PW_HTTPS", "no") - # port = int(os.getenv("PW_PORT", "8675")) - # style = os.getenv("PW_STYLE", "clear") + ".js" - # 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) - # control_secret = os.getenv("PW_CONTROL_SECRET", "") - # gw_pwd = os.getenv("PW_GW_PWD", None) - # neg_solar = os.getenv("PW_NEG_SOLAR", "yes").lower() == "yes" - class CONFIG_TYPE(StrEnum): """_summary_ @@ -147,31 +123,86 @@ class CONFIG_TYPE(StrEnum): PW_TIMEOUT = auto() PW_TIMEZONE = auto() + +class PROXY_STATS_TYPE(StrEnum): + """_summary_ + + Args: + StrEnum (_type_): _description_ + """ + AUTH_MODE = auto() + CF = auto() + CLEAR = auto() + CLOUDMODE = auto() + CONFIG = auto() + COUNTER = auto() + ERRORS = auto() + FLEETAPI = auto() + GETS = auto() + MEM = auto() + MODE = auto() + POSTS = auto() + PW3 = auto() + PYPOWERWALL = auto() + SITE_NAME = auto() + SITEID = auto() + START = auto() + TEDAPI = auto() + TEDAPI_MODE = auto() + TIMEOUT = auto() + TS = auto() + UPTIME = auto() + URI = auto() + + # Configuration for Proxy - Check for environmental variables # and always use those if available (required for Docker) # Configuration - Environment variables type PROXY_CONFIG = Dict[CONFIG_TYPE, str | int | bool | None] -CONFIG: PROXY_CONFIG = { - CONFIG_TYPE.PW_AUTH_MODE: os.getenv(CONFIG_TYPE.PW_AUTH_MODE, "cookie"), - CONFIG_TYPE.PW_AUTH_PATH: os.getenv(CONFIG_TYPE.PW_AUTH_PATH, ""), - CONFIG_TYPE.PW_BIND_ADDRESS: os.getenv(CONFIG_TYPE.PW_BIND_ADDRESS, ""), - CONFIG_TYPE.PW_BROWSER_CACHE: int(os.getenv(CONFIG_TYPE.PW_BROWSER_CACHE, "0")), - CONFIG_TYPE.PW_CACHE_EXPIRE: int(os.getenv(CONFIG_TYPE.PW_CACHE_EXPIRE, "5")), - CONFIG_TYPE.PW_CONTROL_SECRET: os.getenv(CONFIG_TYPE.PW_CONTROL_SECRET, ""), - CONFIG_TYPE.PW_DEBUG: bool(os.getenv(CONFIG_TYPE.PW_DEBUG, "no").lower() == "yes"), - CONFIG_TYPE.PW_EMAIL: os.getenv(CONFIG_TYPE.PW_EMAIL, "email@example.com"), - CONFIG_TYPE.PW_GW_PWD: os.getenv(CONFIG_TYPE.PW_GW_PWD, None), - CONFIG_TYPE.PW_HOST: os.getenv(CONFIG_TYPE.PW_HOST, ""), - CONFIG_TYPE.PW_HTTPS: os.getenv(CONFIG_TYPE.PW_HTTPS, "no"), - CONFIG_TYPE.PW_NEG_SOLAR: bool(os.getenv(CONFIG_TYPE.PW_NEG_SOLAR, "yes").lower() == "yes"), - CONFIG_TYPE.PW_PASSWORD: os.getenv(CONFIG_TYPE.PW_PASSWORD, ""), - CONFIG_TYPE.PW_POOL_MAXSIZE: int(os.getenv(CONFIG_TYPE.PW_POOL_MAXSIZE, "15")), - CONFIG_TYPE.PW_PORT: int(os.getenv(CONFIG_TYPE.PW_PORT, "8675")), - CONFIG_TYPE.PW_SITEID: os.getenv(CONFIG_TYPE.PW_SITEID, None), - CONFIG_TYPE.PW_STYLE: os.getenv(CONFIG_TYPE.PW_STYLE, "clear") + ".js", - CONFIG_TYPE.PW_TIMEOUT: int(os.getenv(CONFIG_TYPE.PW_TIMEOUT, "5")), - CONFIG_TYPE.PW_TIMEZONE: os.getenv(CONFIG_TYPE.PW_TIMEZONE, "America/Los_Angeles") -} +CONFIGS: List[PROXY_CONFIG] = [ + { + CONFIG_TYPE.PW_AUTH_MODE: os.getenv(CONFIG_TYPE.PW_AUTH_MODE, "cookie"), + CONFIG_TYPE.PW_AUTH_PATH: os.getenv(CONFIG_TYPE.PW_AUTH_PATH, ""), + CONFIG_TYPE.PW_BIND_ADDRESS: os.getenv(CONFIG_TYPE.PW_BIND_ADDRESS, ""), + CONFIG_TYPE.PW_BROWSER_CACHE: int(os.getenv(CONFIG_TYPE.PW_BROWSER_CACHE, "0")), + CONFIG_TYPE.PW_CACHE_EXPIRE: int(os.getenv(CONFIG_TYPE.PW_CACHE_EXPIRE, "5")), + CONFIG_TYPE.PW_CONTROL_SECRET: os.getenv(CONFIG_TYPE.PW_CONTROL_SECRET, ""), + CONFIG_TYPE.PW_DEBUG: bool(os.getenv(CONFIG_TYPE.PW_DEBUG, "no").lower() == "yes"), + CONFIG_TYPE.PW_EMAIL: os.getenv(CONFIG_TYPE.PW_EMAIL, "email@example.com"), + CONFIG_TYPE.PW_GW_PWD: os.getenv(CONFIG_TYPE.PW_GW_PWD, None), + CONFIG_TYPE.PW_HOST: os.getenv(CONFIG_TYPE.PW_HOST, ""), + CONFIG_TYPE.PW_HTTPS: os.getenv(CONFIG_TYPE.PW_HTTPS, "no"), + CONFIG_TYPE.PW_NEG_SOLAR: bool(os.getenv(CONFIG_TYPE.PW_NEG_SOLAR, "yes").lower() == "yes"), + CONFIG_TYPE.PW_PASSWORD: os.getenv(CONFIG_TYPE.PW_PASSWORD, ""), + CONFIG_TYPE.PW_POOL_MAXSIZE: int(os.getenv(CONFIG_TYPE.PW_POOL_MAXSIZE, "15")), + CONFIG_TYPE.PW_PORT: int(os.getenv(CONFIG_TYPE.PW_PORT, "8675")), + CONFIG_TYPE.PW_SITEID: os.getenv(CONFIG_TYPE.PW_SITEID, None), + CONFIG_TYPE.PW_STYLE: os.getenv(CONFIG_TYPE.PW_STYLE, "clear") + ".js", + CONFIG_TYPE.PW_TIMEOUT: int(os.getenv(CONFIG_TYPE.PW_TIMEOUT, "5")), + CONFIG_TYPE.PW_TIMEZONE: os.getenv(CONFIG_TYPE.PW_TIMEZONE, "America/Los_Angeles") + }, + { + CONFIG_TYPE.PW_AUTH_MODE: os.getenv(CONFIG_TYPE.PW_AUTH_MODE, "cookie"), + CONFIG_TYPE.PW_AUTH_PATH: os.getenv(CONFIG_TYPE.PW_AUTH_PATH, ""), + CONFIG_TYPE.PW_BIND_ADDRESS: os.getenv(CONFIG_TYPE.PW_BIND_ADDRESS, ""), + CONFIG_TYPE.PW_BROWSER_CACHE: int(os.getenv(CONFIG_TYPE.PW_BROWSER_CACHE, "0")), + CONFIG_TYPE.PW_CACHE_EXPIRE: int(os.getenv(CONFIG_TYPE.PW_CACHE_EXPIRE, "5")), + CONFIG_TYPE.PW_CONTROL_SECRET: os.getenv(CONFIG_TYPE.PW_CONTROL_SECRET, ""), + CONFIG_TYPE.PW_DEBUG: bool(os.getenv(CONFIG_TYPE.PW_DEBUG, "no").lower() == "yes"), + CONFIG_TYPE.PW_EMAIL: os.getenv(CONFIG_TYPE.PW_EMAIL, "email@example.com"), + CONFIG_TYPE.PW_GW_PWD: os.getenv(CONFIG_TYPE.PW_GW_PWD, None), + CONFIG_TYPE.PW_HOST: os.getenv(CONFIG_TYPE.PW_HOST, ""), + CONFIG_TYPE.PW_HTTPS: os.getenv(CONFIG_TYPE.PW_HTTPS, "no"), + CONFIG_TYPE.PW_NEG_SOLAR: bool(os.getenv(CONFIG_TYPE.PW_NEG_SOLAR, "yes").lower() == "yes"), + CONFIG_TYPE.PW_PASSWORD: os.getenv(CONFIG_TYPE.PW_PASSWORD, ""), + CONFIG_TYPE.PW_POOL_MAXSIZE: int(os.getenv(CONFIG_TYPE.PW_POOL_MAXSIZE, "15")), + CONFIG_TYPE.PW_PORT: int(os.getenv(CONFIG_TYPE.PW_PORT, "8675")), + CONFIG_TYPE.PW_SITEID: os.getenv(CONFIG_TYPE.PW_SITEID, None), + CONFIG_TYPE.PW_STYLE: os.getenv(CONFIG_TYPE.PW_STYLE, "clear") + ".js", + CONFIG_TYPE.PW_TIMEOUT: int(os.getenv(CONFIG_TYPE.PW_TIMEOUT, "5")), + CONFIG_TYPE.PW_TIMEZONE: os.getenv(CONFIG_TYPE.PW_TIMEZONE, "America/Los_Angeles") + } +] # Cache file CONFIG[CONFIG_TYPE.PW_CACHE_FILE] = os.getenv( @@ -205,35 +236,6 @@ def sig_term_handle(signum, frame): signal.signal(signal.SIGTERM, sig_term_handle) -class PROXY_STATS_TYPE(StrEnum): - """_summary_ - - Args: - StrEnum (_type_): _description_ - """ - AUTH_MODE = auto() - CF = auto() - CLEAR = auto() - CLOUDMODE = auto() - CONFIG = auto() - COUNTER = auto() - ERRORS = auto() - FLEETAPI = auto() - GETS = auto() - MEM = auto() - MODE = auto() - POSTS = auto() - PW3 = auto() - PYPOWERWALL = auto() - SITE_NAME = auto() - SITEID = auto() - START = auto() - TEDAPI = auto() - TEDAPI_MODE = auto() - TIMEOUT = auto() - TS = auto() - UPTIME = auto() - URI = auto() # Global Stats @@ -306,8 +308,8 @@ def get_value(a, key): proxystats[PROXY_STATS_TYPE.PW3] = pw.tedapi.pw3 log.info(f"TEDAPI Mode Enabled for Device Vitals ({pw.tedapi_mode})") -def configure_pw_control(pw: pypowerwall.Powerwall) -> pypowerwall.Powerwall: - if not CONFIG[CONFIG_TYPE.PW_CONTROL_SECRET]: +def configure_pw_control(pw: pypowerwall.Powerwall, configuration: PROXY_CONFIG) -> pypowerwall.Powerwall: + if not configuration[CONFIG_TYPE.PW_CONTROL_SECRET]: return None log.info("Control Commands Activating - WARNING: Use with caution!") @@ -317,24 +319,24 @@ def configure_pw_control(pw: pypowerwall.Powerwall) -> pypowerwall.Powerwall: else: pw_control = pypowerwall.Powerwall( "", - CONFIG['PW_PASSWORD'], - CONFIG['PW_EMAIL'], - siteid=CONFIG['PW_SITEID'], - authpath=CONFIG['PW_AUTH_PATH'], - authmode=CONFIG['PW_AUTH_MODE'], - cachefile=CONFIG['PW_CACHE_FILE'], + configuration['PW_PASSWORD'], + configuration['PW_EMAIL'], + siteid=configuration['PW_SITEID'], + authpath=configuration['PW_AUTH_PATH'], + authmode=configuration['PW_AUTH_MODE'], + cachefile=configuration['PW_CACHE_FILE'], auto_select=True ) except Exception as e: log.error(f"Control Mode Failed {e}: Unable to connect to cloud - Run Setup") - CONFIG[CONFIG_TYPE.PW_CONTROL_SECRET] = None + configuration[CONFIG_TYPE.PW_CONTROL_SECRET] = None return None if pw_control: log.info(f"Control Mode Enabled: Cloud Mode ({pw_control.mode}) Connected") else: log.error("Control Mode Failed: Unable to connect to cloud - Run Setup") - CONFIG[CONFIG_TYPE.PW_CONTROL_SECRET] = None + configuration[CONFIG_TYPE.PW_CONTROL_SECRET] = None return None return pw_control @@ -342,8 +344,12 @@ def configure_pw_control(pw: pypowerwall.Powerwall) -> pypowerwall.Powerwall: # pylint: disable=arguments-differ,global-variable-not-assigned # noinspection PyPep8Naming class Handler(BaseHTTPRequestHandler): + def __init__(self, *args, configuration: PROXY_CONFIG, **kwargs): + self.configuration = configuration + super().__init__(*args, **kwargs) + def log_message(self, log_format, *args): - if CONFIG[CONFIG_TYPE.PW_DEBUG]: + if self.configuration[CONFIG_TYPE.PW_DEBUG]: log.debug("%s %s" % (self.address_string(), log_format % args)) def address_string(self): @@ -388,7 +394,7 @@ def handle_control_post(self) -> bool: ) return False - if token != CONFIG['PW_CONTROL_SECRET']: + if token != self.configuration['PW_CONTROL_SECRET']: self.send_json_response( {"unauthorized": "Control Command Token Invalid"}, status_code=HTTPStatus.UNAUTHORIZED @@ -521,7 +527,7 @@ def do_GET(self): def handle_aggregates(self): # Meters - JSON aggregates = pw.poll('/api/meters/aggregates') - if not CONFIG[CONFIG_TYPE.PW_NEG_SOLAR] and aggregates and 'solar' in aggregates: + if not self.configuration[CONFIG_TYPE.PW_NEG_SOLAR] and aggregates and 'solar' in aggregates: solar = aggregates['solar'] if solar and 'instant_power' in solar and solar['instant_power'] < 0: solar['instant_power'] = 0 @@ -554,7 +560,7 @@ def handle_csv(self): solar = pw.solar() or 0 battery = pw.battery() or 0 home = pw.home() or 0 - if not CONFIG[CONFIG_TYPE.PW_NEG_SOLAR] and solar < 0: + if not self.configuration[CONFIG_TYPE.PW_NEG_SOLAR] and solar < 0: solar = 0 # Shift energy from solar to load home -= solar @@ -734,35 +740,46 @@ def handle_version(self): r = {"version": "SolarOnly", "vint": 0} if version is None else {"version": version, "vint": parse_version(version)} self.send_json_response(r) - elif self.path == '/help': + + def handle_help(self): + self.send_response(HTTPStatus.OK) + self.send_header('Content-type', 'text/html') + self.end_headers() # Display friendly help screen link and stats - proxystats[PROXY_STATS_TYPE.TS] = int(time.time()) - delta = proxystats[PROXY_STATS_TYPE.TS] - proxystats[PROXY_STATS_TYPE.START] - proxystats[PROXY_STATS_TYPE.UPTIME] = str(datetime.timedelta(seconds=delta)) - proxystats[PROXY_STATS_TYPE.MEM] = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss - proxystats[PROXY_STATS_TYPE.SITE_NAME] = pw.site_name() - proxystats[PROXY_STATS_TYPE.CLOUDMODE] = pw.cloudmode - proxystats[PROXY_STATS_TYPE.FLEETAPI] = pw.fleetapi + + proxystats.update({ + PROXY_STATS_TYPE.TS: int(time.time()), + PROXY_STATS_TYPE.UPTIME: str(datetime.timedelta(seconds=int(time.time()) - proxystats[PROXY_STATS_TYPE.START])), + PROXY_STATS_TYPE.MEM: resource.getrusage(resource.RUSAGE_SELF).ru_maxrss, + PROXY_STATS_TYPE.SITE_NAME: pw.site_name(), + PROXY_STATS_TYPE.CLOUDMODE: pw.cloudmode, + PROXY_STATS_TYPE.FLEETAPI: pw.fleetapi, + }) + if (pw.cloudmode or pw.fleetapi) and pw.client is not None: proxystats[PROXY_STATS_TYPE.SITEID] = pw.client.siteid proxystats[PROXY_STATS_TYPE.COUNTER] = pw.client.counter proxystats[PROXY_STATS_TYPE.AUTH_MODE] = pw.authmode - contenttype = 'text/html' - message: str = """ - \n\n - \n - \n - \n\n

pyPowerwall [%VER%] Proxy [%BUILD%]

\n\n -

- Click here for API help.

\n\n - \n + message = f""" + + + + + + +

pyPowerwall [{pypowerwall.version}] Proxy [{BUILD}]

+

Click here for API help.

+
StatValue
+ """ - message = message.replace('%VER%', pypowerwall.version).replace('%BUILD%', BUILD) - for i in proxystats: - if i != PROXY_STATS_TYPE.URI and i != PROXY_STATS_TYPE.CONFIG: - message += f'\n' - for i in proxystats[PROXY_STATS_TYPE.URI]: - message += f'\n' + for key, value in proxystats.items(): + if key not in ['uri', 'config']: + message += f'\n' + for uri, count in proxystats[PROXY_STATS_TYPE.URI].items(): + message += f'\n' message += """ @@ -771,8 +788,9 @@ def handle_version(self): Click to view
StatValue
{i}{proxystats[i]}
URI: {i}{proxystats[PROXY_STATS_TYPE.URI][i]}
{key}{value}
URI: {uri}{count}
Config:
""" - for i in proxystats[PROXY_STATS_TYPE.CONFIG]: - message += f'\n' + for key, value in proxystats[PROXY_STATS_TYPE.CONFIG].items(): + display_value = '*' * len(value) if 'PASSWORD' in key or 'SECRET' in key else value + message += f'\n' message += """
{i}{proxystats[PROXY_STATS_TYPE.CONFIG][i]}
{key}{display_value}
@@ -792,6 +810,7 @@ def handle_version(self): """ message += "\n" message += f'\n

Page refresh: {str(datetime.datetime.fromtimestamp(time.time()))}

\n\n' + self.wfile.write(message.encode('utf-8')) def handle_problems(self): self.send_json_response({"problems": []}) @@ -857,12 +876,12 @@ def handle_static_content(self): self.send_response(HTTPStatus.OK) self.send_header('Content-type', 'text/html') if pw.authmode == "token": - self.send_header("Set-Cookie", f"AuthCookie=1234567890;{CONFIG[CONFIG_TYPE.PW_COOKIE_SUFFIX]}") - self.send_header("Set-Cookie", f"UserRecord=1234567890;{CONFIG[CONFIG_TYPE.PW_COOKIE_SUFFIX]}") + self.send_header("Set-Cookie", f"AuthCookie=1234567890;{self.configuration[CONFIG_TYPE.PW_COOKIE_SUFFIX]}") + self.send_header("Set-Cookie", f"UserRecord=1234567890;{self.configuration[CONFIG_TYPE.PW_COOKIE_SUFFIX]}") else: auth = pw.client.auth - self.send_header("Set-Cookie", f"AuthCookie={auth['AuthCookie']};{CONFIG[CONFIG_TYPE.PW_COOKIE_SUFFIX]}") - self.send_header("Set-Cookie", f"UserRecord={auth['UserRecord']};{CONFIG[CONFIG_TYPE.PW_COOKIE_SUFFIX]}") + self.send_header("Set-Cookie", f"AuthCookie={auth['AuthCookie']};{self.configuration[CONFIG_TYPE.PW_COOKIE_SUFFIX]}") + self.send_header("Set-Cookie", f"UserRecord={auth['UserRecord']};{self.configuration[CONFIG_TYPE.PW_COOKIE_SUFFIX]}") if self.path == "/" or self.path == "": self.path = "/index.html" @@ -871,8 +890,8 @@ def handle_static_content(self): content = content.decode("utf-8").format( VERSION=status.get("version", ""), HASH=status.get("git_hash", ""), - EMAIL=CONFIG['PW_EMAIL'], - STYLE=CONFIG['PW_STYLE'], + EMAIL=self.configuration['PW_EMAIL'], + STYLE=self.configuration['PW_STYLE'], ).encode('utf-8') else: content, content_type = get_static(WEB_ROOT, self.path) @@ -905,14 +924,14 @@ def handle_static_content(self): content = b"Error during proxy" content_type = "text/plain" - if CONFIG['PW_BROWSER_CACHE'] > 0 and content_type in ['text/css', 'application/javascript', 'image/png']: - self.send_header("Cache-Control", f"max-age={CONFIG[CONFIG_TYPE.PW_BROWSER_CACHE]}") + if self.configuration['PW_BROWSER_CACHE'] > 0 and content_type in ['text/css', 'application/javascript', 'image/png']: + self.send_header("Cache-Control", f"max-age={self.configuration[CONFIG_TYPE.PW_BROWSER_CACHE]}") else: self.send_header("Cache-Control", "no-cache, no-store") if self.path.split('?')[0] == "/": - if os.path.exists(os.path.join(WEB_ROOT, CONFIG[CONFIG_TYPE.PW_STYLE])): - content = bytes(inject_js(content, CONFIG[CONFIG_TYPE.PW_STYLE]), 'utf-8') + if os.path.exists(os.path.join(WEB_ROOT, self.configuration[CONFIG_TYPE.PW_STYLE])): + content = bytes(inject_js(content, self.configuration[CONFIG_TYPE.PW_STYLE]), 'utf-8') self.send_header('Content-type', content_type) self.end_headers() @@ -925,110 +944,24 @@ def handle_static_content(self): log.error(f"Error occured while sending PROXY response to client [doGET]: {exc}") - # else: - # # Everything else - Set auth headers required for web application - # proxystats[PROXY_STATS_TYPE.GETS] += 1 - # if pw.authmode == "token": - # # Create bogus cookies - # self.send_header("Set-Cookie", f"AuthCookie=1234567890;{CONFIG[CONFIG_TYPE.PW_COOKIE_SUFFIX]}") - # self.send_header("Set-Cookie", f"UserRecord=1234567890;{CONFIG[CONFIG_TYPE.PW_COOKIE_SUFFIX]}") - # else: - # self.send_header("Set-Cookie", f"AuthCookie={pw.client.auth['AuthCookie']};{CONFIG[CONFIG_TYPE.PW_COOKIE_SUFFIX]}") - # self.send_header("Set-Cookie", f"UserRecord={pw.client.auth['UserRecord']};{CONFIG[CONFIG_TYPE.PW_COOKIE_SUFFIX]}") - - # # Serve static assets from web root first, if found. - # # pylint: disable=attribute-defined-outside-init - # if self.path == "/" or self.path == "": - # self.path = "/index.html" - # fcontent, ftype = get_static(WEB_ROOT, self.path) - # # Replace {VARS} with current data - # status = pw.status() - # # convert fcontent to string - # fcontent = fcontent.decode("utf-8") - # # fix the following variables that if they are None, return "" - # fcontent = fcontent.replace("{VERSION}", status["version"] or "") - # fcontent = fcontent.replace("{HASH}", status["git_hash"] or "") - # fcontent = fcontent.replace("{EMAIL}", CONFIG[CONFIG_TYPE.PW_EMAIL]) - # fcontent = fcontent.replace("{STYLE}", CONFIG[CONFIG_TYPE.PW_STYLE]) - # # convert fcontent back to bytes - # fcontent = bytes(fcontent, 'utf-8') - # else: - # fcontent, ftype = get_static(WEB_ROOT, self.path) - # if fcontent: - # log.debug("Served from local web root: {} type {}".format(self.path, ftype)) - # # If not found, serve from Powerwall web server - # elif pw.cloudmode or pw.fleetapi: - # log.debug("Cloud Mode - File not found: {}".format(self.path)) - # fcontent = bytes("Not Found", 'utf-8') - # ftype = "text/plain" - # else: - # # Proxy request to Powerwall web server. - # proxy_path = self.path - # if proxy_path.startswith("/"): - # proxy_path = proxy_path[1:] - # pw_url = f"https://{pw.host}/{proxy_path}" - # log.debug(f"Proxy request to: {pw_url}") - # try: - # if pw.authmode == "token": - # r = pw.client.session.get( - # url=pw_url, - # headers=pw.auth, - # verify=False, - # stream=True, - # timeout=pw.timeout - # ) - # else: - # r = pw.client.session.get( - # url=pw_url, - # cookies=pw.auth, - # verify=False, - # stream=True, - # timeout=pw.timeout - # ) - # fcontent = r.content - # ftype = r.headers['content-type'] - # except AttributeError: - # # Display 404 - # log.debug("File not found: {}".format(self.path)) - # fcontent = bytes("Not Found", 'utf-8') - # ftype = "text/plain" - - # # Allow browser caching, if user permits, only for CSS, JavaScript and PNG images... - # if CONFIG[CONFIG_TYPE.PW_BROWSER_CACHE] > 0 and (ftype == 'text/css' or ftype == 'application/javascript' or ftype == 'image/png'): - # self.send_header("Cache-Control", "max-age={}".format(CONFIG[CONFIG_TYPE.PW_BROWSER_CACHE])) - # else: - # self.send_header("Cache-Control", "no-cache, no-store") - - # # Inject transformations - # if self.path.split('?')[0] == "/": - # if os.path.exists(os.path.join(WEB_ROOT, CONFIG[CONFIG_TYPE.PW_STYLE])): - # fcontent = bytes(inject_js(fcontent, CONFIG[CONFIG_TYPE.PW_STYLE]), 'utf-8') - - # self.send_header('Content-type', '{}'.format(ftype)) - # self.end_headers() - # try: - # self.wfile.write(fcontent) - # except Exception as exc: - # if "Broken pipe" in str(exc): - # log.debug(f"Client disconnected before payload sent [doGET]: {exc}") - # return - # log.error(f"Error occured while sending PROXY response to client [doGET]: {exc}") - # return - - - - # # Send headers and payload - # try: - # self.send_header('Content-type', contenttype) - # self.send_header('Content-Length', str(len(message))) - # self.send_header("Access-Control-Allow-Origin", "*") - # self.end_headers() - # self.wfile.write(message.encode("utf8")) - # except Exception as exc: - # log.debug(f"Socket broken sending API response to client [doGET]: {exc}") - -class ThreadingHTTPServer(ThreadingMixIn, HTTPServer): - daemon_threads = True +def run_server(host, port, handler, enable_https=False): + with ThreadingHTTPServer((host, port), handler) as server: + if enable_https: + log.debug(f"Activating HTTPS on {host}:{port}") + server.socket = ssl.wrap_socket( + server.socket, + certfile=os.path.join(os.path.dirname(__file__), 'localhost.pem'), + server_side=True, + ssl_version=ssl.PROTOCOL_TLSv1_2, + ca_certs=None, + do_handshake_on_connect=True + ) + try: + log.info(f"Starting server on {host}:{port}") + server.serve_forever() + except (Exception, KeyboardInterrupt, SystemExit): + log.info(f"Server on {host}:{port} stopped") + sys.exit(0) if __name__ == '__main__': # Connect to Powerwall @@ -1061,26 +994,24 @@ class ThreadingHTTPServer(ThreadingMixIn, HTTPServer): pw_control = configure_pw_control(pw) - # noinspection PyTypeChecker - with ThreadingHTTPServer((CONFIG[CONFIG_TYPE.PW_BIND_ADDRESS], CONFIG[CONFIG_TYPE.PW_PORT]), Handler) as server: - if CONFIG[CONFIG_TYPE.PW_HTTPS] == "yes": - # Activate HTTPS - log.debug("Activating HTTPS") - # pylint: disable=deprecated-method - server.socket = ssl.wrap_socket( - server.socket, - certfile=os.path.join(os.path.dirname(__file__), 'localhost.pem'), - server_side=True, - ssl_version=ssl.PROTOCOL_TLSv1_2, - ca_certs=None, - do_handshake_on_connect=True - ) - - # noinspection PyBroadException - try: - server.serve_forever() - except (Exception, KeyboardInterrupt, SystemExit): - print(' CANCEL \n') - - log.info("pyPowerwall Proxy Stopped") - sys.exit(0) + # Start the first server + server1_thread = threading.Thread( + target=run_server, + args=( + CONFIG[CONFIG_TYPE.PW_BIND_ADDRESS], # Host + CONFIG[CONFIG_TYPE.PW_PORT], # Port + Handler, # Handler + CONFIG[CONFIG_TYPE.PW_HTTPS] == "yes" # HTTPS + ) + ) + + # Start the second server (different port or address) + server2_thread = threading.Thread( + target=run_server, + args=( + CONFIG[CONFIG_TYPE.PW_BIND_ADDRESS], # Host + CONFIG[CONFIG_TYPE.PW_PORT_2], # Second Port (ensure it's different) + Handler, # Handler + CONFIG[CONFIG_TYPE.PW_HTTPS_2] == "yes" # HTTPS for second server + ) + ) From d0ed440d595ca40d07955362b73b77d63c832607 Mon Sep 17 00:00:00 2001 From: Christopher Pitstick Date: Fri, 10 Jan 2025 23:50:44 -0500 Subject: [PATCH 11/35] Working on multiple pypowerwalls --- proxy/requirements.txt | 2 +- proxy/server.py | 296 ++++++++++++++++++++++++----------------- 2 files changed, 176 insertions(+), 122 deletions(-) diff --git a/proxy/requirements.txt b/proxy/requirements.txt index d230597..33a1a12 100644 --- a/proxy/requirements.txt +++ b/proxy/requirements.txt @@ -1,2 +1,2 @@ -pypowerwall==0.12.2 +pypowerwall==0.12.3 bs4==0.0.2 diff --git a/proxy/server.py b/proxy/server.py index 744247f..1517b46 100755 --- a/proxy/server.py +++ b/proxy/server.py @@ -51,6 +51,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any, Dict, Final, Set, List from urllib.parse import parse_qs, urlparse +from pathlib import Path from transform import get_static, inject_js @@ -344,8 +345,10 @@ def configure_pw_control(pw: pypowerwall.Powerwall, configuration: PROXY_CONFIG) # pylint: disable=arguments-differ,global-variable-not-assigned # noinspection PyPep8Naming class Handler(BaseHTTPRequestHandler): - def __init__(self, *args, configuration: PROXY_CONFIG, **kwargs): + def __init__(self, *args, configuration: PROXY_CONFIG, pw: pypowerwall.Powerwall, pw_control: pypowerwall.Powerwall, **kwargs): self.configuration = configuration + self.pw = pw + self.pw_control = pw_control super().__init__(*args, **kwargs) def log_message(self, log_format, *args): @@ -371,7 +374,7 @@ def send_json_response(self, data, status_code=HTTPStatus.OK, content_type='appl def handle_control_post(self) -> bool: """Handle control POST requests.""" - if not pw_control: + if not self.pw_control: proxystats[PROXY_STATS_TYPE.ERRORS] += 1 self.send_json_response( {"error": "Control Commands Disabled - Set PW_CONTROL_SECRET to enable"}, @@ -403,10 +406,10 @@ def handle_control_post(self) -> bool: if action == 'reserve': if not value: - self.send_json_response({"reserve": pw_control.get_reserve()}) + self.send_json_response({"reserve": self.pw_control.get_reserve()}) return True elif value.isdigit(): - result = pw_control.set_reserve(int(value)) + result = self.pw_control.set_reserve(int(value)) log.info(f"Control Command: Set Reserve to {value}") self.send_json_response(result) return True @@ -417,10 +420,10 @@ def handle_control_post(self) -> bool: ) elif action == 'mode': if not value: - self.send_json_response({"mode": pw_control.get_mode()}) + self.send_json_response({"mode": self.pw_control.get_mode()}) return True elif value in ['self_consumption', 'backup', 'autonomous']: - result = pw_control.set_mode(value) + result = self.pw_control.set_mode(value) log.info(f"Control Command: Set Mode to {value}") self.send_json_response(result) return True @@ -477,7 +480,7 @@ def do_GET(self): '/help': self.handle_help, '/api/troubleshooting/problems': self.handle_problems, } - + if path in path_handlers: path_handlers[path]() elif path in DISABLED: @@ -495,17 +498,17 @@ def do_GET(self): self.handle_fleetapi(path) elif self.path.startswith('/control/reserve'): # Current battery reserve level - if not pw_control: + if not self.pw_control: message = '{"error": "Control Commands Disabled - Set PW_CONTROL_SECRET to enable"}' else: - message = '{"reserve": %s}' % pw_control.get_reserve() + message = '{"reserve": %s}' % self.pw_control.get_reserve() self.send_json_response(json.loads(message)) elif self.path.startswith('/control/mode'): # Current operating mode - if not pw_control: + if not self.pw_control: message = '{"error": "Control Commands Disabled - Set PW_CONTROL_SECRET to enable"}' else: - message = '{"mode": "%s"}' % pw_control.get_mode() + message = '{"mode": "%s"}' % self.pw_control.get_mode() self.send_json_response(json.loads(message)) else: self.handle_static_content() @@ -526,7 +529,7 @@ def do_GET(self): def handle_aggregates(self): # Meters - JSON - aggregates = pw.poll('/api/meters/aggregates') + aggregates = self.pw.poll('/api/meters/aggregates') if not self.configuration[CONFIG_TYPE.PW_NEG_SOLAR] and aggregates and 'solar' in aggregates: solar = aggregates['solar'] if solar and 'instant_power' in solar and solar['instant_power'] < 0: @@ -538,28 +541,28 @@ def handle_aggregates(self): def handle_soe(self): - soe = pw.poll('/api/system_status/soe', jsonformat=True) + soe = self.pw.poll('/api/system_status/soe', jsonformat=True) self.send_json_response(json.loads(soe)) def handle_soe(self): - soe = pw.poll('/api/system_status/soe', jsonformat=True) + soe = self.pw.poll('/api/system_status/soe', jsonformat=True) self.send_json_response(json.loads(soe)) def handle_grid_status(self): - grid_status = pw.poll('/api/system_status/grid_status', jsonformat=True) + grid_status = self.pw.poll('/api/system_status/grid_status', jsonformat=True) self.send_json_response(json.loads(grid_status)) def handle_csv(self): # Grid,Home,Solar,Battery,Level - CSV contenttype = 'text/plain; charset=utf-8' - batterylevel = pw.level() or 0 - grid = pw.grid() or 0 - solar = pw.solar() or 0 - battery = pw.battery() or 0 - home = pw.home() or 0 + batterylevel = self.pw.level() or 0 + grid = self.pw.grid() or 0 + solar = self.pw.solar() or 0 + battery = self.pw.battery() or 0 + home = self.pw.home() or 0 if not self.configuration[CONFIG_TYPE.PW_NEG_SOLAR] and solar < 0: solar = 0 # Shift energy from solar to load @@ -569,12 +572,12 @@ def handle_csv(self): def handle_vitals(self): - vitals = pw.vitals(jsonformat=True) or {} + vitals = self.pw.vitals(jsonformat=True) or {} self.send_json_response(json.loads(vitals)) def handle_strings(self): - strings = pw.strings(jsonformat=True) or {} + strings = self.pw.strings(jsonformat=True) or {} self.send_json_response(json.loads(strings)) @@ -583,14 +586,14 @@ def handle_stats(self): PROXY_STATS_TYPE.TS: int(time.time()), PROXY_STATS_TYPE.UPTIME: str(datetime.timedelta(seconds=(proxystats[PROXY_STATS_TYPE.TS] - proxystats[PROXY_STATS_TYPE.START]))), PROXY_STATS_TYPE.MEM: resource.getrusage(resource.RUSAGE_SELF).ru_maxrss, - PROXY_STATS_TYPE.SITE_NAME: pw.site_name(), - PROXY_STATS_TYPE.CLOUDMODE: pw.cloudmode, - PROXY_STATS_TYPE.FLEETAPI: pw.fleetapi, - PROXY_STATS_TYPE.AUTH_MODE: pw.authmode + PROXY_STATS_TYPE.SITE_NAME: self.pw.site_name(), + PROXY_STATS_TYPE.CLOUDMODE: self.pw.cloudmode, + PROXY_STATS_TYPE.FLEETAPI: self.pw.fleetapi, + PROXY_STATS_TYPE.AUTH_MODE: self.pw.authmode }) - if (pw.cloudmode or pw.fleetapi) and pw.client: - proxystats[PROXY_STATS_TYPE.SITEID] = pw.client.siteid - proxystats[PROXY_STATS_TYPE.COUNTER] = pw.client.counter + if (self.pw.cloudmode or self.pw.fleetapi) and self.pw.client: + proxystats[PROXY_STATS_TYPE.SITEID] = self.pw.client.siteid + proxystats[PROXY_STATS_TYPE.COUNTER] = self.pw.client.counter self.send_json_response(proxystats) @@ -607,30 +610,30 @@ def handle_stats_clear(self): def handle_temps(self): - temps = pw.temps(jsonformat=True) or {} + temps = self.pw.temps(jsonformat=True) or {} self.send_json_response(json.loads(temps)) def handle_temps_pw(self): - temps = pw.temps() or {} + temps = self.pw.temps() or {} pw_temp = {f"PW{idx}_temp": temp for idx, temp in enumerate(temps.values(), 1)} self.send_json_response(pw_temp) def handle_alerts(self): - alerts = pw.alerts(jsonformat=True) or [] + alerts = self.pw.alerts(jsonformat=True) or [] self.send_json_response(alerts) def handle_alerts_pw(self): - alerts = pw.alerts() or [] + alerts = self.pw.alerts() or [] pw_alerts = {alert: 1 for alert in alerts} self.send_json_response(pw_alerts) def handle_freq(self): fcv = {} - system_status = pw.system_status() or {} + system_status = self.pw.system_status() or {} blocks = system_status.get("battery_blocks", []) for idx, block in enumerate(blocks, 1): fcv.update({ @@ -646,7 +649,7 @@ def handle_freq(self): f"PW{idx}_f_out": get_value(block, "f_out"), f"PW{idx}_i_out": get_value(block, "i_out"), }) - vitals = pw.vitals() or {} + vitals = self.pw.vitals() or {} for idx, (device, data) in enumerate(vitals.items()): if device.startswith('TEPINV'): fcv.update({ @@ -657,7 +660,7 @@ def handle_freq(self): }) if device.startswith(('TESYNC', 'TEMSA')): fcv.update({key: value for key, value in data.items() if key.startswith(('ISLAND', 'METER'))}) - fcv["grid_status"] = pw.grid_status(type="numeric") + fcv["grid_status"] = self.pw.grid_status(type="numeric") self.send_json_response(fcv) @@ -665,7 +668,7 @@ def handle_pod(self): # Powerwall Battery Data pod = {} # Get Individual Powerwall Battery Data - system_status = pw.system_status() or {} + system_status = self.pw.system_status() or {} blocks = system_status.get("battery_blocks", []) for idx, block in enumerate(blocks, 1): pod.update({ @@ -706,7 +709,7 @@ def handle_pod(self): f"PW{idx}_version": get_value(block, "version") }) - vitals = pw.vitals() or {} + vitals = self.pw.vitals() or {} for idx, (device, data) in enumerate(vitals.items(), 1): if not device.startswith('TEPOD'): continue @@ -729,14 +732,14 @@ def handle_pod(self): pod.update({ "nominal_full_pack_energy": get_value(system_status, 'nominal_full_pack_energy'), "nominal_energy_remaining": get_value(system_status, 'nominal_energy_remaining'), - "time_remaining_hours": pw.get_time_remaining(), - "backup_reserve_percent": pw.get_reserve() + "time_remaining_hours": self.pw.get_time_remaining(), + "backup_reserve_percent": self.pw.get_reserve() }) self.send_json_response(pod) def handle_version(self): - version = pw.version() + version = self.pw.version() r = {"version": "SolarOnly", "vint": 0} if version is None else {"version": version, "vint": parse_version(version)} self.send_json_response(r) @@ -751,15 +754,15 @@ def handle_help(self): PROXY_STATS_TYPE.TS: int(time.time()), PROXY_STATS_TYPE.UPTIME: str(datetime.timedelta(seconds=int(time.time()) - proxystats[PROXY_STATS_TYPE.START])), PROXY_STATS_TYPE.MEM: resource.getrusage(resource.RUSAGE_SELF).ru_maxrss, - PROXY_STATS_TYPE.SITE_NAME: pw.site_name(), - PROXY_STATS_TYPE.CLOUDMODE: pw.cloudmode, - PROXY_STATS_TYPE.FLEETAPI: pw.fleetapi, + PROXY_STATS_TYPE.SITE_NAME: self.pw.site_name(), + PROXY_STATS_TYPE.CLOUDMODE: self.pw.cloudmode, + PROXY_STATS_TYPE.FLEETAPI: self.pw.fleetapi, }) - if (pw.cloudmode or pw.fleetapi) and pw.client is not None: - proxystats[PROXY_STATS_TYPE.SITEID] = pw.client.siteid - proxystats[PROXY_STATS_TYPE.COUNTER] = pw.client.counter - proxystats[PROXY_STATS_TYPE.AUTH_MODE] = pw.authmode + if (self.pw.cloudmode or self.pw.fleetapi) and self.pw.client is not None: + proxystats[PROXY_STATS_TYPE.SITEID] = self.pw.client.siteid + proxystats[PROXY_STATS_TYPE.COUNTER] = self.pw.client.counter + proxystats[PROXY_STATS_TYPE.AUTH_MODE] = self.pw.authmode message = f""" @@ -789,7 +792,7 @@ def handle_help(self): """ for key, value in proxystats[PROXY_STATS_TYPE.CONFIG].items(): - display_value = '*' * len(value) if 'PASSWORD' in key or 'SECRET' in key else value + display_value = '*' * len(value) if any(substr in key for substr in ['PASSWORD', 'SECRET']) else value message += f'\n' message += """
{key}{display_value}
@@ -822,11 +825,11 @@ def handle_tedapi(self, path): return commands = { - '/tedapi/config': pw.tedapi.get_config, - '/tedapi/status': pw.tedapi.get_status, - '/tedapi/components': pw.tedapi.get_components, - '/tedapi/battery': pw.tedapi.get_battery_blocks, - '/tedapi/controller': pw.tedapi.get_device_controller, + '/tedapi/config': self.pw.tedapi.get_config, + '/tedapi/status': self.pw.tedapi.get_status, + '/tedapi/components': self.pw.tedapi.get_components, + '/tedapi/battery': self.pw.tedapi.get_battery_blocks, + '/tedapi/controller': self.pw.tedapi.get_device_controller, } command = commands.get(path) if command: @@ -839,14 +842,14 @@ def handle_tedapi(self, path): def handle_cloud(self, path): - if not pw.cloudmode or pw.fleetapi: + if not self.pw.cloudmode or self.pw.fleetapi: self.send_json_response({"error": "Cloud API not enabled"}, status_code=HTTPStatus.BAD_REQUEST) return commands = { - '/cloud/battery': pw.client.get_battery, - '/cloud/power': pw.client.get_site_power, - '/cloud/config': pw.client.get_site_config, + '/cloud/battery': self.pw.client.get_battery, + '/cloud/power': self.pw.client.get_site_power, + '/cloud/config': self.pw.client.get_site_config, } command = commands.get(path) if command: @@ -856,13 +859,13 @@ def handle_cloud(self, path): def handle_fleetapi(self, path): - if not pw.fleetapi: + if not self.pw.fleetapi: self.send_json_response({"error": "FleetAPI not enabled"}, status_code=HTTPStatus.BAD_REQUEST) return commands = { - '/fleetapi/info': pw.client.get_site_info, - '/fleetapi/status': pw.client.get_live_status, + '/fleetapi/info': self.pw.client.get_site_info, + '/fleetapi/status': self.pw.client.get_live_status, } command = commands.get(path) if command: @@ -875,18 +878,18 @@ def handle_static_content(self): proxystats[PROXY_STATS_TYPE.GETS] += 1 self.send_response(HTTPStatus.OK) self.send_header('Content-type', 'text/html') - if pw.authmode == "token": + if self.pw.authmode == "token": self.send_header("Set-Cookie", f"AuthCookie=1234567890;{self.configuration[CONFIG_TYPE.PW_COOKIE_SUFFIX]}") self.send_header("Set-Cookie", f"UserRecord=1234567890;{self.configuration[CONFIG_TYPE.PW_COOKIE_SUFFIX]}") else: - auth = pw.client.auth + auth = self.pw.client.auth self.send_header("Set-Cookie", f"AuthCookie={auth['AuthCookie']};{self.configuration[CONFIG_TYPE.PW_COOKIE_SUFFIX]}") self.send_header("Set-Cookie", f"UserRecord={auth['UserRecord']};{self.configuration[CONFIG_TYPE.PW_COOKIE_SUFFIX]}") if self.path == "/" or self.path == "": self.path = "/index.html" content, content_type = get_static(WEB_ROOT, self.path) - status = pw.status() + status = self.pw.status() content = content.decode("utf-8").format( VERSION=status.get("version", ""), HASH=status.get("git_hash", ""), @@ -899,23 +902,23 @@ def handle_static_content(self): if content: log.debug("Served from local web root: {} type {}".format(self.path, content_type)) # If not found, serve from Powerwall web server - elif pw.cloudmode or pw.fleetapi: + elif self.pw.cloudmode or self.pw.fleetapi: log.debug(f"Cloud Mode - File not found: {self.path}") content = b"Not Found" content_type = "text/plain" else: # Proxy request to Powerwall web server. - pw_url = f"https://{pw.host}/{self.path.lstrip('/')}" + pw_url = f"https://{self.pw.host}/{self.path.lstrip('/')}" log.debug("Proxy request to: %s", pw_url) try: - session = pw.client.session + session = self.pw.client.session response = session.get( url=pw_url, - headers=pw.auth if pw.authmode == "token" else None, - cookies=None if pw.authmode == "token" else pw.auth, + headers=self.pw.auth if self.pw.authmode == "token" else None, + cookies=None if self.pw.authmode == "token" else self.pw.auth, verify=False, stream=True, - timeout=pw.timeout, + timeout=self.pw.timeout, ) content = response.content content_type = response.headers.get('content-type', 'text/html') @@ -943,6 +946,48 @@ def handle_static_content(self): return log.error(f"Error occured while sending PROXY response to client [doGET]: {exc}") +def read_config_file(file_path: Path) -> List[PROXY_CONFIG]: + configs: List[PROXY_CONFIG] = [] + current_config: PROXY_CONFIG = {} + + # Read the file and parse lines + try: + with file_path.open('r') as file: + for line in file: + # Ignore comments and empty lines + line = line.strip() + if not line or line.startswith("#"): + continue + + # Detect new configuration block + if line.startswith("[Powerwall"): + if current_config: + configs.append(current_config) + current_config = {} + + # Split key and value + elif "=" in line: + key, value = line.split("=", 1) + key, value = key.strip(), value.strip() + + # Ensure the key is valid + if not hasattr(CONFIG_TYPE, key): + print(f"Warning: Invalid configuration key '{key}' ignored.") + continue + + # Handle boolean values + if value.lower() in ["yes", "no"]: + value = value.lower() == "yes" + + # Assign to current configuration dictionary + current_config[key] = value + + # Add the last configuration block + if current_config: + configs.append(current_config) + except FileNotFoundError: + print(f"Configuration file '{file_path}' not found.") + def run_server(host, port, handler, enable_https=False): with ThreadingHTTPServer((host, port), handler) as server: @@ -963,55 +1008,64 @@ def run_server(host, port, handler, enable_https=False): log.info(f"Server on {host}:{port} stopped") sys.exit(0) -if __name__ == '__main__': - # Connect to Powerwall - # TODO: Add support for multiple Powerwalls - try: - pw = pypowerwall.Powerwall( - host=CONFIG[CONFIG_TYPE.PW_HOST], - password=CONFIG[CONFIG_TYPE.PW_PASSWORD], - email=CONFIG[CONFIG_TYPE.PW_EMAIL], - timezone=CONFIG[CONFIG_TYPE.PW_TIMEZONE], - cache_expire=CONFIG[CONFIG_TYPE.PW_CACHE_EXPIRE], - timeout=CONFIG[CONFIG_TYPE.PW_TIMEOUT], - pool_maxsize=CONFIG[CONFIG_TYPE.PW_POOL_MAXSIZE], - siteid=CONFIG[CONFIG_TYPE.PW_SITEID], - authpath=CONFIG[CONFIG_TYPE.PW_AUTH_PATH], - authmode=CONFIG[CONFIG_TYPE.PW_AUTH_MODE], - cachefile=CONFIG[CONFIG_TYPE.PW_CACHE_FILE], - auto_select=True, - retry_modes=True, - gw_pwd=CONFIG[CONFIG_TYPE.PW_GW_PWD] - ) - except Exception as e: - log.error(e) - log.error("Fatal Error: Unable to connect. Please fix config and restart.") - while True: - try: - time.sleep(5) # Infinite loop to keep container running - except (KeyboardInterrupt, SystemExit): - sys.exit(0) - - pw_control = configure_pw_control(pw) - - # Start the first server - server1_thread = threading.Thread( - target=run_server, - args=( - CONFIG[CONFIG_TYPE.PW_BIND_ADDRESS], # Host - CONFIG[CONFIG_TYPE.PW_PORT], # Port - Handler, # Handler - CONFIG[CONFIG_TYPE.PW_HTTPS] == "yes" # HTTPS - ) - ) - - # Start the second server (different port or address) - server2_thread = threading.Thread( - target=run_server, - args=( - CONFIG[CONFIG_TYPE.PW_BIND_ADDRESS], # Host - CONFIG[CONFIG_TYPE.PW_PORT_2], # Second Port (ensure it's different) - Handler, # Handler - CONFIG[CONFIG_TYPE.PW_HTTPS_2] == "yes" # HTTPS for second server + +def main() -> None: + configs = read_config_file(Path("pypowerwall.env")) + servers: List[threading.Thread] = [] + + for config in configs: + try: + pw = pypowerwall.Powerwall( + host=config[CONFIG_TYPE.PW_HOST], + password=config[CONFIG_TYPE.PW_PASSWORD], + email=config[CONFIG_TYPE.PW_EMAIL], + timezone=config[CONFIG_TYPE.PW_TIMEZONE], + cache_expire=config[CONFIG_TYPE.PW_CACHE_EXPIRE], + timeout=config[CONFIG_TYPE.PW_TIMEOUT], + pool_maxsize=config[CONFIG_TYPE.PW_POOL_MAXSIZE], + siteid=config[CONFIG_TYPE.PW_SITEID], + authpath=config[CONFIG_TYPE.PW_AUTH_PATH], + authmode=config[CONFIG_TYPE.PW_AUTH_MODE], + cachefile=config[CONFIG_TYPE.PW_CACHE_FILE], + auto_select=True, + retry_modes=True, + gw_pwd=config[CONFIG_TYPE.PW_GW_PWD] + ) + except Exception as e: + log.error(e) + log.error("Fatal Error: Unable to connect. Please fix config and restart.") + while True: + try: + time.sleep(5) # Infinite loop to keep container running + except (KeyboardInterrupt, SystemExit): + sys.exit(0) + + pw_control = configure_pw_control(pw) + + server = threading.Thread( + target=run_server, + args=( + config[CONFIG_TYPE.PW_BIND_ADDRESS], # Host + config[CONFIG_TYPE.PW_PORT], # Port + Handler(configuration=config, pw=pw), # Handler + config[CONFIG_TYPE.PW_HTTPS] == "yes" # HTTPS + ) ) - ) + servers.append(server) + + # Start all server threads + for s in servers: + s.start() + s.start() + + # Wait for both threads to finish + for s in servers: + s.join() + s.join() + + log.info("pyPowerwall Proxy Stopped") + sys.exit(0) + + +if __name__ == '__main__': + main() From 46c56721e861cef4087871b5146dbddc86f1ba0b Mon Sep 17 00:00:00 2001 From: Christopher Pitstick Date: Sat, 11 Jan 2025 01:00:38 -0500 Subject: [PATCH 12/35] A bit more refactoring --- proxy/server.py | 38 ++++++++++++++++++-------------------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/proxy/server.py b/proxy/server.py index 1517b46..703c1bb 100755 --- a/proxy/server.py +++ b/proxy/server.py @@ -49,7 +49,7 @@ from enum import StrEnum, auto from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from typing import Any, Dict, Final, Set, List +from typing import Any, Dict, Final, Set, List, Tuple from urllib.parse import parse_qs, urlparse from pathlib import Path @@ -265,11 +265,6 @@ def sig_term_handle(signum, frame): PROXY_STATS_TYPE.URI: {} } -log.info( - f"pyPowerwall [{pypowerwall.version}] Proxy Server [{BUILD}] - {CONFIG[CONFIG_TYPE.PW_HTTP_TYPE]} Port {CONFIG['PW_PORT']}{' - DEBUG' if CONFIG[CONFIG_TYPE.PW_DEBUG] else ''}" -) -log.info("pyPowerwall Proxy Started") - # Check for cache expire time limit below 5s if CONFIG['PW_CACHE_EXPIRE'] < 5: log.warning(f"Cache expiration set below 5s (PW_CACHE_EXPIRE={CONFIG['PW_CACHE_EXPIRE']})") @@ -342,8 +337,7 @@ def configure_pw_control(pw: pypowerwall.Powerwall, configuration: PROXY_CONFIG) return pw_control -# pylint: disable=arguments-differ,global-variable-not-assigned -# noinspection PyPep8Naming + class Handler(BaseHTTPRequestHandler): def __init__(self, *args, configuration: PROXY_CONFIG, pw: pypowerwall.Powerwall, pw_control: pypowerwall.Powerwall, **kwargs): self.configuration = configuration @@ -557,7 +551,6 @@ def handle_grid_status(self): def handle_csv(self): # Grid,Home,Solar,Battery,Level - CSV - contenttype = 'text/plain; charset=utf-8' batterylevel = self.pw.level() or 0 grid = self.pw.grid() or 0 solar = self.pw.solar() or 0 @@ -968,7 +961,9 @@ def read_config_file(file_path: Path) -> List[PROXY_CONFIG]: # Split key and value elif "=" in line: key, value = line.split("=", 1) - key, value = key.strip(), value.strip() + def aggressive_strip(v: str) -> str: + return v.strip().strip("'").strip('"') + key, value = aggressive_strip(key), aggressive_strip(value) # Ensure the key is valid if not hasattr(CONFIG_TYPE, key): @@ -1011,7 +1006,7 @@ def run_server(host, port, handler, enable_https=False): def main() -> None: configs = read_config_file(Path("pypowerwall.env")) - servers: List[threading.Thread] = [] + servers: List[Tuple[PROXY_CONFIG, threading.Thread]] = [] for config in configs: try: @@ -1041,27 +1036,30 @@ def main() -> None: sys.exit(0) pw_control = configure_pw_control(pw) + handler = Handler(configuration=config, pw=pw, pw_control=pw_control) server = threading.Thread( target=run_server, args=( config[CONFIG_TYPE.PW_BIND_ADDRESS], # Host config[CONFIG_TYPE.PW_PORT], # Port - Handler(configuration=config, pw=pw), # Handler + handler, # Handler config[CONFIG_TYPE.PW_HTTPS] == "yes" # HTTPS ) ) servers.append(server) # Start all server threads - for s in servers: - s.start() - s.start() - - # Wait for both threads to finish - for s in servers: - s.join() - s.join() + for config, server in zip(configs, servers): + log.info( + f"pyPowerwall [{pypowerwall.version}] Proxy Server [{BUILD}] - {config[CONFIG_TYPE.PW_HTTP_TYPE]} Port {config['PW_PORT']}{' - DEBUG' if config[CONFIG_TYPE.PW_DEBUG] else ''}" + ) + log.info("pyPowerwall Proxy Started\n") + server.start() + + # Wait for all server threads to finish + for server in servers: + server.join() log.info("pyPowerwall Proxy Stopped") sys.exit(0) From 8c4e4731307b784d0bafb0cb453a8fc254373a5c Mon Sep 17 00:00:00 2001 From: Christopher Pitstick Date: Sat, 11 Jan 2025 21:30:21 -0500 Subject: [PATCH 13/35] Continuing to refactor --- proxy/server.py | 163 +++++++++++++++++++++++------------------------- 1 file changed, 78 insertions(+), 85 deletions(-) diff --git a/proxy/server.py b/proxy/server.py index 703c1bb..6ed3049 100755 --- a/proxy/server.py +++ b/proxy/server.py @@ -15,16 +15,16 @@ Local Powerwall Mode The default mode for this proxy is to connect to a local Powerwall to pull data. This works with the Tesla Energy Gateway (TEG) for - Powerwall 1, 2 and +. It will also support pulling /vitals and /strings + Powerwall 1, 2 and +. It will also support pulling /vitals and /strings data if available. Set: PW_HOST to Powerwall Address and PW_PASSWORD to use this mode. Cloud Mode An optional mode is to connect to the Tesla Cloud to pull data. This requires that you have a Tesla Account and have registered your - Tesla Solar System or Powerwall with the Tesla App. It requires that - you run the setup 'python -m pypowerwall setup' process to create the - required API keys and tokens. This mode doesn't support /vitals or + Tesla Solar System or Powerwall with the Tesla App. It requires that + you run the setup 'python -m pypowerwall setup' process to create the + required API keys and tokens. This mode doesn't support /vitals or /strings data. Set: PW_EMAIL and leave PW_HOST blank to use this mode. @@ -58,9 +58,10 @@ import pypowerwall from pypowerwall import parse_version -BUILD = "t67" +BUILD: Final[str] = "t67" +UTF_8: Final[str] = "utf-8" -ALLOWLIST = Set[str] = set([ +ALLOWLIST = Final[Set[str]] = set([ '/api/auth/toggle/supported', '/api/customer', '/api/customer/registration', @@ -89,7 +90,7 @@ '/api/troubleshooting/problems', ]) -DISABLED = Set[str] = set([ +DISABLED = Final[Set[str]] = set([ '/api/customer/registration', ]) WEB_ROOT: Final[str] = os.path.join(os.path.dirname(__file__), "web") @@ -160,67 +161,6 @@ class PROXY_STATS_TYPE(StrEnum): # and always use those if available (required for Docker) # Configuration - Environment variables type PROXY_CONFIG = Dict[CONFIG_TYPE, str | int | bool | None] -CONFIGS: List[PROXY_CONFIG] = [ - { - CONFIG_TYPE.PW_AUTH_MODE: os.getenv(CONFIG_TYPE.PW_AUTH_MODE, "cookie"), - CONFIG_TYPE.PW_AUTH_PATH: os.getenv(CONFIG_TYPE.PW_AUTH_PATH, ""), - CONFIG_TYPE.PW_BIND_ADDRESS: os.getenv(CONFIG_TYPE.PW_BIND_ADDRESS, ""), - CONFIG_TYPE.PW_BROWSER_CACHE: int(os.getenv(CONFIG_TYPE.PW_BROWSER_CACHE, "0")), - CONFIG_TYPE.PW_CACHE_EXPIRE: int(os.getenv(CONFIG_TYPE.PW_CACHE_EXPIRE, "5")), - CONFIG_TYPE.PW_CONTROL_SECRET: os.getenv(CONFIG_TYPE.PW_CONTROL_SECRET, ""), - CONFIG_TYPE.PW_DEBUG: bool(os.getenv(CONFIG_TYPE.PW_DEBUG, "no").lower() == "yes"), - CONFIG_TYPE.PW_EMAIL: os.getenv(CONFIG_TYPE.PW_EMAIL, "email@example.com"), - CONFIG_TYPE.PW_GW_PWD: os.getenv(CONFIG_TYPE.PW_GW_PWD, None), - CONFIG_TYPE.PW_HOST: os.getenv(CONFIG_TYPE.PW_HOST, ""), - CONFIG_TYPE.PW_HTTPS: os.getenv(CONFIG_TYPE.PW_HTTPS, "no"), - CONFIG_TYPE.PW_NEG_SOLAR: bool(os.getenv(CONFIG_TYPE.PW_NEG_SOLAR, "yes").lower() == "yes"), - CONFIG_TYPE.PW_PASSWORD: os.getenv(CONFIG_TYPE.PW_PASSWORD, ""), - CONFIG_TYPE.PW_POOL_MAXSIZE: int(os.getenv(CONFIG_TYPE.PW_POOL_MAXSIZE, "15")), - CONFIG_TYPE.PW_PORT: int(os.getenv(CONFIG_TYPE.PW_PORT, "8675")), - CONFIG_TYPE.PW_SITEID: os.getenv(CONFIG_TYPE.PW_SITEID, None), - CONFIG_TYPE.PW_STYLE: os.getenv(CONFIG_TYPE.PW_STYLE, "clear") + ".js", - CONFIG_TYPE.PW_TIMEOUT: int(os.getenv(CONFIG_TYPE.PW_TIMEOUT, "5")), - CONFIG_TYPE.PW_TIMEZONE: os.getenv(CONFIG_TYPE.PW_TIMEZONE, "America/Los_Angeles") - }, - { - CONFIG_TYPE.PW_AUTH_MODE: os.getenv(CONFIG_TYPE.PW_AUTH_MODE, "cookie"), - CONFIG_TYPE.PW_AUTH_PATH: os.getenv(CONFIG_TYPE.PW_AUTH_PATH, ""), - CONFIG_TYPE.PW_BIND_ADDRESS: os.getenv(CONFIG_TYPE.PW_BIND_ADDRESS, ""), - CONFIG_TYPE.PW_BROWSER_CACHE: int(os.getenv(CONFIG_TYPE.PW_BROWSER_CACHE, "0")), - CONFIG_TYPE.PW_CACHE_EXPIRE: int(os.getenv(CONFIG_TYPE.PW_CACHE_EXPIRE, "5")), - CONFIG_TYPE.PW_CONTROL_SECRET: os.getenv(CONFIG_TYPE.PW_CONTROL_SECRET, ""), - CONFIG_TYPE.PW_DEBUG: bool(os.getenv(CONFIG_TYPE.PW_DEBUG, "no").lower() == "yes"), - CONFIG_TYPE.PW_EMAIL: os.getenv(CONFIG_TYPE.PW_EMAIL, "email@example.com"), - CONFIG_TYPE.PW_GW_PWD: os.getenv(CONFIG_TYPE.PW_GW_PWD, None), - CONFIG_TYPE.PW_HOST: os.getenv(CONFIG_TYPE.PW_HOST, ""), - CONFIG_TYPE.PW_HTTPS: os.getenv(CONFIG_TYPE.PW_HTTPS, "no"), - CONFIG_TYPE.PW_NEG_SOLAR: bool(os.getenv(CONFIG_TYPE.PW_NEG_SOLAR, "yes").lower() == "yes"), - CONFIG_TYPE.PW_PASSWORD: os.getenv(CONFIG_TYPE.PW_PASSWORD, ""), - CONFIG_TYPE.PW_POOL_MAXSIZE: int(os.getenv(CONFIG_TYPE.PW_POOL_MAXSIZE, "15")), - CONFIG_TYPE.PW_PORT: int(os.getenv(CONFIG_TYPE.PW_PORT, "8675")), - CONFIG_TYPE.PW_SITEID: os.getenv(CONFIG_TYPE.PW_SITEID, None), - CONFIG_TYPE.PW_STYLE: os.getenv(CONFIG_TYPE.PW_STYLE, "clear") + ".js", - CONFIG_TYPE.PW_TIMEOUT: int(os.getenv(CONFIG_TYPE.PW_TIMEOUT, "5")), - CONFIG_TYPE.PW_TIMEZONE: os.getenv(CONFIG_TYPE.PW_TIMEZONE, "America/Los_Angeles") - } -] - -# Cache file -CONFIG[CONFIG_TYPE.PW_CACHE_FILE] = os.getenv( - CONFIG_TYPE.PW_CACHE_FILE, - os.path.join(CONFIG[CONFIG_TYPE.PW_AUTH_PATH], ".powerwall") if CONFIG[CONFIG_TYPE.PW_AUTH_PATH] else ".powerwall" -) - -# HTTP/S configuration -if CONFIG[CONFIG_TYPE.PW_HTTPS].lower() == "yes": - CONFIG[CONFIG_TYPE.PW_COOKIE_SUFFIX] = "path=/;SameSite=None;Secure;" - CONFIG[CONFIG_TYPE.PW_HTTP_TYPE] = "HTTPS" -elif CONFIG[CONFIG_TYPE.PW_HTTPS].lower() == "http": - CONFIG[CONFIG_TYPE.PW_COOKIE_SUFFIX] = "path=/;SameSite=None;Secure;" - CONFIG[CONFIG_TYPE.PW_HTTP_TYPE] = "HTTP" -else: - CONFIG[CONFIG_TYPE.PW_COOKIE_SUFFIX] = "path=/;" - CONFIG[CONFIG_TYPE.PW_HTTP_TYPE] = "HTTP" # Logging configuration log = logging.getLogger("proxy") @@ -344,7 +284,7 @@ def __init__(self, *args, configuration: PROXY_CONFIG, pw: pypowerwall.Powerwall self.pw = pw self.pw_control = pw_control super().__init__(*args, **kwargs) - + def log_message(self, log_format, *args): if self.configuration[CONFIG_TYPE.PW_DEBUG]: log.debug("%s %s" % (self.address_string(), log_format % args)) @@ -353,7 +293,7 @@ def address_string(self): # replace function to avoid lookup delays hostaddr, hostport = self.client_address[:2] return hostaddr - + def send_json_response(self, data, status_code=HTTPStatus.OK, content_type='application/json'): response = json.dumps(data) try: @@ -362,10 +302,10 @@ def send_json_response(self, data, status_code=HTTPStatus.OK, content_type='appl self.send_header('Content-Length', str(len(response))) self.send_header("Access-Control-Allow-Origin", "*") self.end_headers() - self.wfile.write(response.encode("utf8")) + self.wfile.write(response.encode(UTF_8)) except Exception as exc: - log.debug("Error sending response: %s", exc) - + log.debug(f"Error sending response: {exc}") + def handle_control_post(self) -> bool: """Handle control POST requests.""" if not self.pw_control: @@ -380,7 +320,7 @@ def handle_control_post(self) -> bool: action = urlparse(self.path).path.split('/')[2] content_length = int(self.headers.get('Content-Length', 0)) post_data = self.rfile.read(content_length) - query_params = parse_qs(post_data.decode('utf-8')) + query_params = parse_qs(post_data.decode(UTF_8)) value = query_params.get('value', [''])[0] token = query_params.get('token', [''])[0] except Exception as er: @@ -451,7 +391,7 @@ def do_GET(self): proxystats[PROXY_STATS_TYPE.GETS] += 1 parsed_path = urlparse(self.path) path = parsed_path.path - + # Map paths to handler functions path_handlers = { '/aggregates': self.handle_aggregates, @@ -533,7 +473,7 @@ def handle_aggregates(self): aggregates['load']['instant_power'] -= solar['instant_power'] self.send_json_response(aggregates) - + def handle_soe(self): soe = self.pw.poll('/api/system_status/soe', jsonformat=True) self.send_json_response(json.loads(soe)) @@ -649,7 +589,7 @@ def handle_freq(self): f"PW{idx}_name": device, f"PW{idx}_PINV_Fout": get_value(data, 'PINV_Fout'), f"PW{idx}_PINV_VSplit1": get_value(data, 'PINV_VSplit1'), - f"PW{idx}_PINV_VSplit2": get_value(data, 'PINV_VSplit2') + f"PW{idx}_PINV_VSplit2": get_value(data, 'PINV_VSplit2') }) if device.startswith(('TESYNC', 'TEMSA')): fcv.update({key: value for key, value in data.items() if key.startswith(('ISLAND', 'METER'))}) @@ -806,14 +746,15 @@ def handle_help(self): """ message += "\n" message += f'\n

Page refresh: {str(datetime.datetime.fromtimestamp(time.time()))}

\n\n' - self.wfile.write(message.encode('utf-8')) + self.wfile.write(message.encode(UTF_8)) + def handle_problems(self): self.send_json_response({"problems": []}) def handle_tedapi(self, path): - if not pw.tedapi: + if not self.pw.tedapi: self.send_json_response({"error": "TEDAPI not enabled"}, status_code=HTTPStatus.BAD_REQUEST) return @@ -883,12 +824,12 @@ def handle_static_content(self): self.path = "/index.html" content, content_type = get_static(WEB_ROOT, self.path) status = self.pw.status() - content = content.decode("utf-8").format( + content = content.decode(UTF_8).format( VERSION=status.get("version", ""), HASH=status.get("git_hash", ""), EMAIL=self.configuration['PW_EMAIL'], STYLE=self.configuration['PW_STYLE'], - ).encode('utf-8') + ).encode(UTF_8) else: content, content_type = get_static(WEB_ROOT, self.path) @@ -924,10 +865,10 @@ def handle_static_content(self): self.send_header("Cache-Control", f"max-age={self.configuration[CONFIG_TYPE.PW_BROWSER_CACHE]}") else: self.send_header("Cache-Control", "no-cache, no-store") - + if self.path.split('?')[0] == "/": if os.path.exists(os.path.join(WEB_ROOT, self.configuration[CONFIG_TYPE.PW_STYLE])): - content = bytes(inject_js(content, self.configuration[CONFIG_TYPE.PW_STYLE]), 'utf-8') + content = bytes(inject_js(content, self.configuration[CONFIG_TYPE.PW_STYLE]), UTF_8) self.send_header('Content-type', content_type) self.end_headers() @@ -939,6 +880,33 @@ def handle_static_content(self): return log.error(f"Error occured while sending PROXY response to client [doGET]: {exc}") + +def read_env_config() -> PROXY_CONFIG: + config: PROXY_CONFIG = { + CONFIG_TYPE.PW_AUTH_MODE: os.getenv(CONFIG_TYPE.PW_AUTH_MODE, "cookie"), + CONFIG_TYPE.PW_AUTH_PATH: os.getenv(CONFIG_TYPE.PW_AUTH_PATH, ""), + CONFIG_TYPE.PW_BIND_ADDRESS: os.getenv(CONFIG_TYPE.PW_BIND_ADDRESS, ""), + CONFIG_TYPE.PW_BROWSER_CACHE: int(os.getenv(CONFIG_TYPE.PW_BROWSER_CACHE, "0")), + CONFIG_TYPE.PW_CACHE_EXPIRE: int(os.getenv(CONFIG_TYPE.PW_CACHE_EXPIRE, "5")), + CONFIG_TYPE.PW_CONTROL_SECRET: os.getenv(CONFIG_TYPE.PW_CONTROL_SECRET, ""), + CONFIG_TYPE.PW_DEBUG: bool(os.getenv(CONFIG_TYPE.PW_DEBUG, "no").lower() == "yes"), + CONFIG_TYPE.PW_EMAIL: os.getenv(CONFIG_TYPE.PW_EMAIL, "email@example.com"), + CONFIG_TYPE.PW_GW_PWD: os.getenv(CONFIG_TYPE.PW_GW_PWD, None), + CONFIG_TYPE.PW_HOST: os.getenv(CONFIG_TYPE.PW_HOST, ""), + CONFIG_TYPE.PW_HTTPS: os.getenv(CONFIG_TYPE.PW_HTTPS, "no"), + CONFIG_TYPE.PW_NEG_SOLAR: bool(os.getenv(CONFIG_TYPE.PW_NEG_SOLAR, "yes").lower() == "yes"), + CONFIG_TYPE.PW_PASSWORD: os.getenv(CONFIG_TYPE.PW_PASSWORD, ""), + CONFIG_TYPE.PW_POOL_MAXSIZE: int(os.getenv(CONFIG_TYPE.PW_POOL_MAXSIZE, "15")), + CONFIG_TYPE.PW_PORT: int(os.getenv(CONFIG_TYPE.PW_PORT, "8675")), + CONFIG_TYPE.PW_SITEID: os.getenv(CONFIG_TYPE.PW_SITEID, None), + CONFIG_TYPE.PW_STYLE: os.getenv(CONFIG_TYPE.PW_STYLE, "clear") + ".js", + CONFIG_TYPE.PW_TIMEOUT: int(os.getenv(CONFIG_TYPE.PW_TIMEOUT, "5")), + CONFIG_TYPE.PW_TIMEZONE: os.getenv(CONFIG_TYPE.PW_TIMEZONE, "America/Los_Angeles"), + CONFIG_TYPE.PW_CACHE_FILE: os.getenv(CONFIG_TYPE.PW_CACHE_FILE, "") + } + return config + + def read_config_file(file_path: Path) -> List[PROXY_CONFIG]: configs: List[PROXY_CONFIG] = [] current_config: PROXY_CONFIG = {} @@ -957,7 +925,7 @@ def read_config_file(file_path: Path) -> List[PROXY_CONFIG]: if current_config: configs.append(current_config) current_config = {} - + # Split key and value elif "=" in line: key, value = line.split("=", 1) @@ -984,6 +952,31 @@ def aggressive_strip(v: str) -> str: print(f"Configuration file '{file_path}' not found.") +def build_configuration() -> List[PROXY_CONFIG]: + COOKIE_SUFFIX: Final[str] = "path=/;SameSite=None;Secure;" + + configs: List[PROXY_CONFIG] = [] + configs.append(read_env_config()) + configs.extend(read_config_file(Path("pypowerwall.env"))) + + for config in configs: + # HTTP/S configuration + if config[CONFIG_TYPE.PW_HTTPS].lower() == "yes": + config[CONFIG_TYPE.PW_COOKIE_SUFFIX] = COOKIE_SUFFIX + config[CONFIG_TYPE.PW_HTTP_TYPE] = "HTTPS" + elif config[CONFIG_TYPE.PW_HTTPS].lower() == "http": + config[CONFIG_TYPE.PW_COOKIE_SUFFIX] = COOKIE_SUFFIX + config[CONFIG_TYPE.PW_HTTP_TYPE] = "HTTP" + else: + config[CONFIG_TYPE.PW_COOKIE_SUFFIX] = "path=/;" + config[CONFIG_TYPE.PW_HTTP_TYPE] = "HTTP" + + if config[CONFIG_TYPE.PW_CACHE_FILE] == "": + config[CONFIG_TYPE.PW_CACHE_FILE] = os.path.join(config[CONFIG_TYPE.PW_AUTH_PATH], ".powerwall") if config[CONFIG_TYPE.PW_AUTH_PATH] else ".powerwall" + + return configs + + def run_server(host, port, handler, enable_https=False): with ThreadingHTTPServer((host, port), handler) as server: if enable_https: @@ -1005,7 +998,7 @@ def run_server(host, port, handler, enable_https=False): def main() -> None: - configs = read_config_file(Path("pypowerwall.env")) + servers: List[Tuple[PROXY_CONFIG, threading.Thread]] = [] for config in configs: From 5d43a77fb09814f566a65a7e0845f976235cf58f Mon Sep 17 00:00:00 2001 From: Christopher Pitstick Date: Sat, 11 Jan 2025 23:13:58 -0500 Subject: [PATCH 14/35] Restore missing functions --- proxy/server.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/proxy/server.py b/proxy/server.py index 6ed3049..995d2e6 100755 --- a/proxy/server.py +++ b/proxy/server.py @@ -287,14 +287,14 @@ def __init__(self, *args, configuration: PROXY_CONFIG, pw: pypowerwall.Powerwall def log_message(self, log_format, *args): if self.configuration[CONFIG_TYPE.PW_DEBUG]: - log.debug("%s %s" % (self.address_string(), log_format % args)) + log.debug(f"{self.address_string()} {log_format % args}") def address_string(self): # replace function to avoid lookup delays hostaddr, hostport = self.client_address[:2] return hostaddr - def send_json_response(self, data, status_code=HTTPStatus.OK, content_type='application/json'): + def send_json_response(self, data, status_code=HTTPStatus.OK, content_type='application/json') -> bool: response = json.dumps(data) try: self.send_response(status_code) @@ -302,9 +302,12 @@ def send_json_response(self, data, status_code=HTTPStatus.OK, content_type='appl self.send_header('Content-Length', str(len(response))) self.send_header("Access-Control-Allow-Origin", "*") self.end_headers() - self.wfile.write(response.encode(UTF_8)) + if self.wfile.write(response.encode(UTF_8)) > 0: + return True except Exception as exc: log.debug(f"Error sending response: {exc}") + return False + def handle_control_post(self) -> bool: """Handle control POST requests.""" @@ -474,14 +477,14 @@ def handle_aggregates(self): self.send_json_response(aggregates) - def handle_soe(self): + def handle_soe(self) -> bool: soe = self.pw.poll('/api/system_status/soe', jsonformat=True) - self.send_json_response(json.loads(soe)) + return self.send_json_response(json.loads(soe)) - def handle_soe(self): - soe = self.pw.poll('/api/system_status/soe', jsonformat=True) - self.send_json_response(json.loads(soe)) + def handle_soe_scaled(self) -> bool: + level = self.pw.level(scale=True) + return self.send_json_response({"percentage": level}) def handle_grid_status(self): @@ -752,6 +755,9 @@ def handle_help(self): def handle_problems(self): self.send_json_response({"problems": []}) + def handle_allowlist(self, path) -> bool: + response = self.pw.poll(path, jsonformat=True) + return self.send_json_response(json.loads(response)) def handle_tedapi(self, path): if not self.pw.tedapi: From 9dc5d500b24a0889e0203efe35edb275e964d2a2 Mon Sep 17 00:00:00 2001 From: Christopher Pitstick Date: Sun, 12 Jan 2025 00:07:50 -0500 Subject: [PATCH 15/35] WIP on configuration loading --- proxy/server.py | 252 +++++++++++++++++++++++++++++------------------- 1 file changed, 152 insertions(+), 100 deletions(-) diff --git a/proxy/server.py b/proxy/server.py index 995d2e6..571f034 100755 --- a/proxy/server.py +++ b/proxy/server.py @@ -94,6 +94,28 @@ '/api/customer/registration', ]) WEB_ROOT: Final[str] = os.path.join(os.path.dirname(__file__), "web") +SERVER_DEBUG: Final[bool] = bool(os.getenv("PW_DEBUG", "no").lower() == "yes") + +# Logging configuration +log = logging.getLogger("proxy") +logging.basicConfig(format='%(levelname)s:%(message)s', level=logging.INFO) +log.setLevel(logging.DEBUG if SERVER_DEBUG else logging.INFO) + +# Signal handler - Exit on SIGTERM +# noinspection PyUnusedLocal +def sig_term_handle(signum, frame): + raise SystemExit + +signal.signal(signal.SIGTERM, sig_term_handle) + +if SERVER_DEBUG: + pypowerwall.set_debug(True) + + +class CONFIGURATION_SOURCE(StrEnum): + ENVIRONMENT_VARIABLES = auto() + CONFIGURATION_FILE = auto() + class CONFIG_TYPE(StrEnum): """_summary_ @@ -110,7 +132,6 @@ class CONFIG_TYPE(StrEnum): PW_CACHE_FILE = auto() PW_CONTROL_SECRET = auto() PW_COOKIE_SUFFIX = auto() - PW_DEBUG = auto() PW_EMAIL = auto() PW_GW_PWD = auto() PW_HOST = auto() @@ -156,58 +177,11 @@ class PROXY_STATS_TYPE(StrEnum): UPTIME = auto() URI = auto() - # Configuration for Proxy - Check for environmental variables # and always use those if available (required for Docker) # Configuration - Environment variables type PROXY_CONFIG = Dict[CONFIG_TYPE, str | int | bool | None] - -# Logging configuration -log = logging.getLogger("proxy") -logging.basicConfig(format='%(levelname)s:%(message)s', level=logging.INFO) -log.setLevel(logging.DEBUG if CONFIG[CONFIG_TYPE.PW_DEBUG] else logging.INFO) - -if CONFIG[CONFIG_TYPE.PW_DEBUG]: - pypowerwall.set_debug(True) - -# Signal handler - Exit on SIGTERM -# noinspection PyUnusedLocal -def sig_term_handle(signum, frame): - raise SystemExit - -signal.signal(signal.SIGTERM, sig_term_handle) - - - -# Global Stats -proxystats: Dict[PROXY_STATS_TYPE, str | int | bool | None | Dict[Any, Any]] = { - PROXY_STATS_TYPE.CF: CONFIG[CONFIG_TYPE.PW_CACHE_FILE], - PROXY_STATS_TYPE.CLEAR: int(time.time()), - PROXY_STATS_TYPE.CLOUDMODE: False, - PROXY_STATS_TYPE.CONFIG: CONFIG.copy(), - PROXY_STATS_TYPE.COUNTER: 0, - PROXY_STATS_TYPE.ERRORS: 0, - PROXY_STATS_TYPE.FLEETAPI: False, - PROXY_STATS_TYPE.GETS: 0, - PROXY_STATS_TYPE.MEM: 0, - PROXY_STATS_TYPE.MODE: "Unknown", - PROXY_STATS_TYPE.POSTS: 0, - PROXY_STATS_TYPE.PW3: False, - PROXY_STATS_TYPE.PYPOWERWALL: f"{pypowerwall.version} Proxy {BUILD}", - PROXY_STATS_TYPE.SITE_NAME: "", - PROXY_STATS_TYPE.SITEID: None, - PROXY_STATS_TYPE.START: int(time.time()), - PROXY_STATS_TYPE.TEDAPI_MODE: "off", - PROXY_STATS_TYPE.TEDAPI: False, - PROXY_STATS_TYPE.TIMEOUT: 0, - PROXY_STATS_TYPE.TS: int(time.time()), - PROXY_STATS_TYPE.UPTIME: "", - PROXY_STATS_TYPE.URI: {} -} - -# Check for cache expire time limit below 5s -if CONFIG['PW_CACHE_EXPIRE'] < 5: - log.warning(f"Cache expiration set below 5s (PW_CACHE_EXPIRE={CONFIG['PW_CACHE_EXPIRE']})") +type PROXY_STATS = Dict[PROXY_STATS_TYPE, str | int | bool | None | PROXY_CONFIG | Dict[str, int]] # Get Value Function - Key to Value or Return Null def get_value(a, key): @@ -216,33 +190,6 @@ def get_value(a, key): log.debug(f"Missing key in payload [{key}]") return value -site_name = pw.site_name() or "Unknown" -if pw.cloudmode or pw.fleetapi: - if pw.fleetapi: - proxystats[PROXY_STATS_TYPE.MODE] = "FleetAPI" - log.info("pyPowerwall Proxy Server - FleetAPI Mode") - else: - proxystats[PROXY_STATS_TYPE.MODE] = "Cloud" - log.info("pyPowerwall Proxy Server - Cloud Mode") - log.info(f"Connected to Site ID {pw.client.siteid} ({site_name.strip()})") - if CONFIG[CONFIG_TYPE.PW_SITEID] is not None and CONFIG[CONFIG_TYPE.PW_SITEID] != str(pw.client.siteid): - log.info("Switch to Site ID %s" % CONFIG[CONFIG_TYPE.PW_SITEID]) - if not pw.client.change_site(CONFIG[CONFIG_TYPE.PW_SITEID]): - log.error("Fatal Error: Unable to connect. Please fix config and restart.") - while True: - try: - time.sleep(5) # Infinite loop to keep container running - except (KeyboardInterrupt, SystemExit): - sys.exit(0) -else: - proxystats[PROXY_STATS_TYPE.MODE] = "Local" - log.info("pyPowerwall Proxy Server - Local Mode") - log.info(f"Connected to Energy Gateway {CONFIG[CONFIG_TYPE.PW_HOST]} ({site_name.strip()})") - if pw.tedapi: - proxystats[PROXY_STATS_TYPE.TEDAPI] = True - proxystats[PROXY_STATS_TYPE.TEDAPI_MODE] = pw.tedapi_mode - proxystats[PROXY_STATS_TYPE.PW3] = pw.tedapi.pw3 - log.info(f"TEDAPI Mode Enabled for Device Vitals ({pw.tedapi_mode})") def configure_pw_control(pw: pypowerwall.Powerwall, configuration: PROXY_CONFIG) -> pypowerwall.Powerwall: if not configuration[CONFIG_TYPE.PW_CONTROL_SECRET]: @@ -283,6 +230,61 @@ def __init__(self, *args, configuration: PROXY_CONFIG, pw: pypowerwall.Powerwall self.configuration = configuration self.pw = pw self.pw_control = pw_control + + proxystats: PROXY_STATS = { + PROXY_STATS_TYPE.CF: configuration[CONFIG_TYPE.PW_CACHE_FILE], + PROXY_STATS_TYPE.CLEAR: int(time.time()), + PROXY_STATS_TYPE.CLOUDMODE: False, + PROXY_STATS_TYPE.CONFIG: configuration.copy(), + PROXY_STATS_TYPE.COUNTER: 0, + PROXY_STATS_TYPE.ERRORS: 0, + PROXY_STATS_TYPE.FLEETAPI: False, + PROXY_STATS_TYPE.GETS: 0, + PROXY_STATS_TYPE.MEM: 0, + PROXY_STATS_TYPE.MODE: "Unknown", + PROXY_STATS_TYPE.POSTS: 0, + PROXY_STATS_TYPE.PW3: False, + PROXY_STATS_TYPE.PYPOWERWALL: f"{pypowerwall.version} Proxy {BUILD}", + PROXY_STATS_TYPE.SITE_NAME: "", + PROXY_STATS_TYPE.SITEID: None, + PROXY_STATS_TYPE.START: int(time.time()), + PROXY_STATS_TYPE.TEDAPI_MODE: "off", + PROXY_STATS_TYPE.TEDAPI: False, + PROXY_STATS_TYPE.TIMEOUT: 0, + PROXY_STATS_TYPE.TS: int(time.time()), + PROXY_STATS_TYPE.UPTIME: "", + PROXY_STATS_TYPE.URI: {} + } + self.proxystats = proxystats + + site_name = pw.site_name() or "Unknown" + if pw.cloudmode or pw.fleetapi: + if pw.fleetapi: + proxystats[PROXY_STATS_TYPE.MODE] = "FleetAPI" + log.info("pyPowerwall Proxy Server - FleetAPI Mode") + else: + proxystats[PROXY_STATS_TYPE.MODE] = "Cloud" + log.info("pyPowerwall Proxy Server - Cloud Mode") + log.info(f"Connected to Site ID {pw.client.siteid} ({site_name.strip()})") + if configuration[CONFIG_TYPE.PW_SITEID] is not None and configuration[CONFIG_TYPE.PW_SITEID] != str(pw.client.siteid): + log.info(f"Switch to Site ID {configuration[CONFIG_TYPE.PW_SITEID]}") + if not pw.client.change_site(configuration[CONFIG_TYPE.PW_SITEID]): + log.error("Fatal Error: Unable to connect. Please fix config and restart.") + while True: + try: + time.sleep(5) # Infinite loop to keep container running + except (KeyboardInterrupt, SystemExit): + sys.exit(0) + else: + proxystats[PROXY_STATS_TYPE.MODE] = "Local" + log.info("pyPowerwall Proxy Server - Local Mode") + log.info(f"Connected to Energy Gateway {configuration[CONFIG_TYPE.PW_HOST]} ({site_name.strip()})") + if pw.tedapi: + proxystats[PROXY_STATS_TYPE.TEDAPI] = True + proxystats[PROXY_STATS_TYPE.TEDAPI_MODE] = pw.tedapi_mode + proxystats[PROXY_STATS_TYPE.PW3] = pw.tedapi.pw3 + log.info(f"TEDAPI Mode Enabled for Device Vitals ({pw.tedapi_mode})") + super().__init__(*args, **kwargs) def log_message(self, log_format, *args): @@ -306,13 +308,14 @@ def send_json_response(self, data, status_code=HTTPStatus.OK, content_type='appl return True except Exception as exc: log.debug(f"Error sending response: {exc}") + self.proxystats[PROXY_STATS_TYPE.ERRORS] += 1 return False def handle_control_post(self) -> bool: """Handle control POST requests.""" if not self.pw_control: - proxystats[PROXY_STATS_TYPE.ERRORS] += 1 + self.proxystats[PROXY_STATS_TYPE.ERRORS] += 1 self.send_json_response( {"error": "Control Commands Disabled - Set PW_CONTROL_SECRET to enable"}, status_code=HTTPStatus.BAD_REQUEST @@ -381,7 +384,7 @@ def do_POST(self): """Handle POST requests.""" if self.path.startswith('/control'): stat = PROXY_STATS_TYPE.POSTS if self.handle_control_post() else PROXY_STATS_TYPE.ERRORS - proxystats[stat] += 1 + self.proxystats[stat] += 1 else: self.send_json_response( {"error": "Invalid Request"}, @@ -391,7 +394,7 @@ def do_POST(self): def do_GET(self): """Handle GET requests.""" - proxystats[PROXY_STATS_TYPE.GETS] += 1 + self.proxystats[PROXY_STATS_TYPE.GETS] += 1 parsed_path = urlparse(self.path) path = parsed_path.path @@ -518,9 +521,9 @@ def handle_strings(self): def handle_stats(self): - proxystats.update({ + self.proxystats.update({ PROXY_STATS_TYPE.TS: int(time.time()), - PROXY_STATS_TYPE.UPTIME: str(datetime.timedelta(seconds=(proxystats[PROXY_STATS_TYPE.TS] - proxystats[PROXY_STATS_TYPE.START]))), + PROXY_STATS_TYPE.UPTIME: str(datetime.timedelta(seconds=(self.proxystats[PROXY_STATS_TYPE.TS] - self.proxystats[PROXY_STATS_TYPE.START]))), PROXY_STATS_TYPE.MEM: resource.getrusage(resource.RUSAGE_SELF).ru_maxrss, PROXY_STATS_TYPE.SITE_NAME: self.pw.site_name(), PROXY_STATS_TYPE.CLOUDMODE: self.pw.cloudmode, @@ -528,21 +531,21 @@ def handle_stats(self): PROXY_STATS_TYPE.AUTH_MODE: self.pw.authmode }) if (self.pw.cloudmode or self.pw.fleetapi) and self.pw.client: - proxystats[PROXY_STATS_TYPE.SITEID] = self.pw.client.siteid - proxystats[PROXY_STATS_TYPE.COUNTER] = self.pw.client.counter - self.send_json_response(proxystats) + self.proxystats[PROXY_STATS_TYPE.SITEID] = self.pw.client.siteid + self.proxystats[PROXY_STATS_TYPE.COUNTER] = self.pw.client.counter + self.send_json_response(self.proxystats) def handle_stats_clear(self): # Clear Internal Stats log.debug("Clear internal stats") - proxystats.update({ + self.proxystats.update({ PROXY_STATS_TYPE.GETS: 0, PROXY_STATS_TYPE.ERRORS: 0, PROXY_STATS_TYPE.URI: {}, PROXY_STATS_TYPE.CLEAR: int(time.time()), }) - self.send_json_response(proxystats) + self.send_json_response(self.proxystats) def handle_temps(self): @@ -686,9 +689,9 @@ def handle_help(self): self.end_headers() # Display friendly help screen link and stats - proxystats.update({ + self.proxystats.update({ PROXY_STATS_TYPE.TS: int(time.time()), - PROXY_STATS_TYPE.UPTIME: str(datetime.timedelta(seconds=int(time.time()) - proxystats[PROXY_STATS_TYPE.START])), + PROXY_STATS_TYPE.UPTIME: str(datetime.timedelta(seconds=int(time.time()) - self.proxystats[PROXY_STATS_TYPE.START])), PROXY_STATS_TYPE.MEM: resource.getrusage(resource.RUSAGE_SELF).ru_maxrss, PROXY_STATS_TYPE.SITE_NAME: self.pw.site_name(), PROXY_STATS_TYPE.CLOUDMODE: self.pw.cloudmode, @@ -696,9 +699,9 @@ def handle_help(self): }) if (self.pw.cloudmode or self.pw.fleetapi) and self.pw.client is not None: - proxystats[PROXY_STATS_TYPE.SITEID] = self.pw.client.siteid - proxystats[PROXY_STATS_TYPE.COUNTER] = self.pw.client.counter - proxystats[PROXY_STATS_TYPE.AUTH_MODE] = self.pw.authmode + self.proxystats[PROXY_STATS_TYPE.SITEID] = self.pw.client.siteid + self.proxystats[PROXY_STATS_TYPE.COUNTER] = self.pw.client.counter + self.proxystats[PROXY_STATS_TYPE.AUTH_MODE] = self.pw.authmode message = f""" @@ -714,10 +717,10 @@ def handle_help(self): """ - for key, value in proxystats.items(): + for key, value in self.proxystats.items(): if key not in ['uri', 'config']: message += f'\n' - for uri, count in proxystats[PROXY_STATS_TYPE.URI].items(): + for uri, count in self.proxystats[PROXY_STATS_TYPE.URI].items(): message += f'\n' message += """ @@ -727,7 +730,7 @@ def handle_help(self): Click to view
StatValue
{key}{value}
URI: {uri}{count}
""" - for key, value in proxystats[PROXY_STATS_TYPE.CONFIG].items(): + for key, value in self.proxystats[PROXY_STATS_TYPE.CONFIG].items(): display_value = '*' * len(value) if any(substr in key for substr in ['PASSWORD', 'SECRET']) else value message += f'\n' message += """ @@ -815,7 +818,7 @@ def handle_fleetapi(self, path): def handle_static_content(self): - proxystats[PROXY_STATS_TYPE.GETS] += 1 + self.proxystats[PROXY_STATS_TYPE.GETS] += 1 self.send_response(HTTPStatus.OK) self.send_header('Content-type', 'text/html') if self.pw.authmode == "token": @@ -895,7 +898,6 @@ def read_env_config() -> PROXY_CONFIG: CONFIG_TYPE.PW_BROWSER_CACHE: int(os.getenv(CONFIG_TYPE.PW_BROWSER_CACHE, "0")), CONFIG_TYPE.PW_CACHE_EXPIRE: int(os.getenv(CONFIG_TYPE.PW_CACHE_EXPIRE, "5")), CONFIG_TYPE.PW_CONTROL_SECRET: os.getenv(CONFIG_TYPE.PW_CONTROL_SECRET, ""), - CONFIG_TYPE.PW_DEBUG: bool(os.getenv(CONFIG_TYPE.PW_DEBUG, "no").lower() == "yes"), CONFIG_TYPE.PW_EMAIL: os.getenv(CONFIG_TYPE.PW_EMAIL, "email@example.com"), CONFIG_TYPE.PW_GW_PWD: os.getenv(CONFIG_TYPE.PW_GW_PWD, None), CONFIG_TYPE.PW_HOST: os.getenv(CONFIG_TYPE.PW_HOST, ""), @@ -956,14 +958,60 @@ def aggressive_strip(v: str) -> str: configs.append(current_config) except FileNotFoundError: print(f"Configuration file '{file_path}' not found.") + +import yaml + +#WIP, loading configuration as yaml. + +def load_powerwall_config(file_path): + """ + Loads a YAML configuration file with multiple Powerwall configurations + into a list of dictionaries. + + Args: + file_path (str): Path to the YAML configuration file. + + Returns: + list: A list of dictionaries, where each dictionary represents a Powerwall configuration. + """ + try: + with open(file_path, 'r') as file: + # Load the YAML data + config = yaml.safe_load(file) + + # Convert the named sections to a list of dictionaries + powerwalls = config.get('powerwalls', {}) + return [ + {"name": name, **details} + for name, details in powerwalls.items() + ] + except FileNotFoundError: + print(f"Error: File '{file_path}' not found.") + return [] + except yaml.YAMLError as e: + print(f"Error parsing YAML file: {e}") + return [] + +# Example usage +if __name__ == "__main__": + file_path = "powerwall_config.yaml" # Replace with your YAML file path + powerwall_list = load_powerwall_config(file_path) + for powerwall in powerwall_list: + print(powerwall) def build_configuration() -> List[PROXY_CONFIG]: COOKIE_SUFFIX: Final[str] = "path=/;SameSite=None;Secure;" - + configuration_source: Final[CONFIGURATION_SOURCE] = CONFIGURATION_SOURCE(os.getenv("PW_CONFIGURATION_SOURCE", CONFIGURATION_SOURCE.ENVIRONMENT_VARIABLES)) configs: List[PROXY_CONFIG] = [] - configs.append(read_env_config()) - configs.extend(read_config_file(Path("pypowerwall.env"))) + + if configuration_source == CONFIGURATION_SOURCE.ENVIRONMENT_VARIABLES: + configs.append(read_env_config()) + elif configuration_source == CONFIGURATION_SOURCE.CONFIGURATION_FILE: + configs.extend(read_config_file(Path("pypowerwall.env"))) + else: + log.error("Configuration source misconfigured. This should never happen.") + exit(0) for config in configs: # HTTP/S configuration @@ -980,6 +1028,10 @@ def build_configuration() -> List[PROXY_CONFIG]: if config[CONFIG_TYPE.PW_CACHE_FILE] == "": config[CONFIG_TYPE.PW_CACHE_FILE] = os.path.join(config[CONFIG_TYPE.PW_AUTH_PATH], ".powerwall") if config[CONFIG_TYPE.PW_AUTH_PATH] else ".powerwall" + # Check for cache expire time limit below 5s + if config['PW_CACHE_EXPIRE'] < 5: + log.warning(f"Cache expiration set below 5s for host:port={config[CONFIG_TYPE.PW_HOST]}:{config[CONFIG_TYPE.PW_PORT]} (PW_CACHE_EXPIRE={config[CONFIG_TYPE.PW_CACHE_EXPIRE]})") + return configs @@ -1004,8 +1056,8 @@ def run_server(host, port, handler, enable_https=False): def main() -> None: - servers: List[Tuple[PROXY_CONFIG, threading.Thread]] = [] + configs = build_configuration() for config in configs: try: From 55ef8a4461e6932787987da736f3d1d65f79bc9d Mon Sep 17 00:00:00 2001 From: Christopher Pitstick Date: Sun, 12 Jan 2025 00:13:57 -0500 Subject: [PATCH 16/35] Add configuration file. --- proxy/server.py | 65 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/proxy/server.py b/proxy/server.py index 571f034..3a58220 100755 --- a/proxy/server.py +++ b/proxy/server.py @@ -963,6 +963,71 @@ def aggressive_strip(v: str) -> str: #WIP, loading configuration as yaml. + +# powerwalls: +# powerwall_1: # Configuration for Powerwall 1 +# PW_AUTH_MODE: "cookie" # Authentication mode, default is "cookie" +# PW_AUTH_PATH: "" # Path for authentication, default is empty +# PW_BIND_ADDRESS: "" # Address to bind, default is empty +# PW_BROWSER_CACHE: 0 # Enable browser cache: 0 = disabled, 1 = enabled +# PW_CACHE_EXPIRE: 5 # Cache expiration in minutes +# PW_CONTROL_SECRET: "" # Secret key for secure control +# PW_EMAIL: "email1@example.com" # Email address for notifications +# PW_GW_PWD: null # Gateway password (if required) +# PW_HOST: "192.168.1.10" # Host address of the Tesla Powerwall +# PW_HTTPS: "no" # Use HTTPS (yes/no) +# PW_NEG_SOLAR: true # Allow negative solar values (true/false) +# PW_PASSWORD: "" # Password for authentication +# PW_POOL_MAXSIZE: 15 # Maximum size of the connection pool +# PW_PORT: 8675 # Port number for the connection +# PW_SITEID: "site_1" # Site ID (if required) +# PW_STYLE: "clear.js" # JavaScript file for UI style +# PW_TIMEOUT: 5 # Connection timeout in seconds +# PW_TIMEZONE: "America/Los_Angeles" # Default timezone +# PW_CACHE_FILE: "" # Path to cache file + +# powerwall_2: # Configuration for Powerwall 2 +# PW_AUTH_MODE: "cookie" +# PW_AUTH_PATH: "" +# PW_BIND_ADDRESS: "" +# PW_BROWSER_CACHE: 0 +# PW_CACHE_EXPIRE: 5 +# PW_CONTROL_SECRET: "" +# PW_EMAIL: "email2@example.com" +# PW_GW_PWD: null +# PW_HOST: "192.168.1.11" +# PW_HTTPS: "no" +# PW_NEG_SOLAR: true +# PW_PASSWORD: "" +# PW_POOL_MAXSIZE: 15 +# PW_PORT: 8675 +# PW_SITEID: "site_2" +# PW_STYLE: "clear.js" +# PW_TIMEOUT: 5 +# PW_TIMEZONE: "America/New_York" +# PW_CACHE_FILE: "" + +# powerwall_3: # Configuration for Powerwall 3 +# PW_AUTH_MODE: "cookie" +# PW_AUTH_PATH: "" +# PW_BIND_ADDRESS: "" +# PW_BROWSER_CACHE: 0 +# PW_CACHE_EXPIRE: 5 +# PW_CONTROL_SECRET: "" +# PW_EMAIL: "email3@example.com" +# PW_GW_PWD: null +# PW_HOST: "192.168.1.12" +# PW_HTTPS: "no" +# PW_NEG_SOLAR: true +# PW_PASSWORD: "" +# PW_POOL_MAXSIZE: 15 +# PW_PORT: 8675 +# PW_SITEID: "site_3" +# PW_STYLE: "clear.js" +# PW_TIMEOUT: 5 +# PW_TIMEZONE: "Europe/London" +# PW_CACHE_FILE: "" + def load_powerwall_config(file_path): """ Loads a YAML configuration file with multiple Powerwall configurations From 449201ca769d52fa6b6824b6877dfc3fe3653f90 Mon Sep 17 00:00:00 2001 From: Christopher Pitstick Date: Sun, 12 Jan 2025 16:42:12 -0500 Subject: [PATCH 17/35] Add ability to load multiple configurations from environment variables --- proxy/server.py | 87 ++++++++++++++++++++++++++++++++++++------------- 1 file changed, 64 insertions(+), 23 deletions(-) diff --git a/proxy/server.py b/proxy/server.py index 3a58220..d779be0 100755 --- a/proxy/server.py +++ b/proxy/server.py @@ -890,29 +890,70 @@ def handle_static_content(self): log.error(f"Error occured while sending PROXY response to client [doGET]: {exc}") -def read_env_config() -> PROXY_CONFIG: - config: PROXY_CONFIG = { - CONFIG_TYPE.PW_AUTH_MODE: os.getenv(CONFIG_TYPE.PW_AUTH_MODE, "cookie"), - CONFIG_TYPE.PW_AUTH_PATH: os.getenv(CONFIG_TYPE.PW_AUTH_PATH, ""), - CONFIG_TYPE.PW_BIND_ADDRESS: os.getenv(CONFIG_TYPE.PW_BIND_ADDRESS, ""), - CONFIG_TYPE.PW_BROWSER_CACHE: int(os.getenv(CONFIG_TYPE.PW_BROWSER_CACHE, "0")), - CONFIG_TYPE.PW_CACHE_EXPIRE: int(os.getenv(CONFIG_TYPE.PW_CACHE_EXPIRE, "5")), - CONFIG_TYPE.PW_CONTROL_SECRET: os.getenv(CONFIG_TYPE.PW_CONTROL_SECRET, ""), - CONFIG_TYPE.PW_EMAIL: os.getenv(CONFIG_TYPE.PW_EMAIL, "email@example.com"), - CONFIG_TYPE.PW_GW_PWD: os.getenv(CONFIG_TYPE.PW_GW_PWD, None), - CONFIG_TYPE.PW_HOST: os.getenv(CONFIG_TYPE.PW_HOST, ""), - CONFIG_TYPE.PW_HTTPS: os.getenv(CONFIG_TYPE.PW_HTTPS, "no"), - CONFIG_TYPE.PW_NEG_SOLAR: bool(os.getenv(CONFIG_TYPE.PW_NEG_SOLAR, "yes").lower() == "yes"), - CONFIG_TYPE.PW_PASSWORD: os.getenv(CONFIG_TYPE.PW_PASSWORD, ""), - CONFIG_TYPE.PW_POOL_MAXSIZE: int(os.getenv(CONFIG_TYPE.PW_POOL_MAXSIZE, "15")), - CONFIG_TYPE.PW_PORT: int(os.getenv(CONFIG_TYPE.PW_PORT, "8675")), - CONFIG_TYPE.PW_SITEID: os.getenv(CONFIG_TYPE.PW_SITEID, None), - CONFIG_TYPE.PW_STYLE: os.getenv(CONFIG_TYPE.PW_STYLE, "clear") + ".js", - CONFIG_TYPE.PW_TIMEOUT: int(os.getenv(CONFIG_TYPE.PW_TIMEOUT, "5")), - CONFIG_TYPE.PW_TIMEZONE: os.getenv(CONFIG_TYPE.PW_TIMEZONE, "America/Los_Angeles"), - CONFIG_TYPE.PW_CACHE_FILE: os.getenv(CONFIG_TYPE.PW_CACHE_FILE, "") - } - return config +def check_for_environmental_pw_configs() -> List[str]: + """ + Checks for environment variables with specific suffix patterns and returns the list of matching suffixes. + + This function iterates over predefined suffixes (starting with an empty string and "1") to check if any + configuration-related environment variables are defined. If a match is found, the suffix is added to the + result list. For numeric suffixes, the function dynamically generates and checks the next numeric suffix. + + Returns: + List[str]: A list of suffixes for which environment variables were found. + + Notes: + - Environment variable names are constructed by appending the suffix to the `value` attribute of each + item in `CONFIG_TYPE`. + - The function dynamically appends numeric suffixes based on the highest found numeric suffix. + """ + suffixes_to_check = {"", "1"} + actual_configs = [] + + while suffixes_to_check: + current_suffix = suffixes_to_check.pop() + for config in CONFIG_TYPE: + env_var = f"{config.value}{current_suffix}" + if env_var in os.environ: + actual_configs.append(current_suffix) + break + + if current_suffix.isnumeric(): + next_suffix = str(int(current_suffix) + 1) + if next_suffix not in suffixes_to_check: + suffixes_to_check.append(next_suffix) + + return actual_configs + + +def read_env_configs() -> List[PROXY_CONFIG]: + suffixes = check_for_environmental_pw_configs() + configs: List[PROXY_CONFIG] = [] + for s in suffixes: + def add_suffix(type: CONFIG_TYPE) -> str: + return f"{str(type)}{s}" + config: PROXY_CONFIG = { + CONFIG_TYPE.PW_AUTH_MODE: os.getenv(add_suffix(CONFIG_TYPE.PW_AUTH_MODE), "cookie"), + CONFIG_TYPE.PW_AUTH_PATH: os.getenv(add_suffix(CONFIG_TYPE.PW_AUTH_PATH), ""), + CONFIG_TYPE.PW_BIND_ADDRESS: os.getenv(add_suffix(CONFIG_TYPE.PW_BIND_ADDRESS), ""), + CONFIG_TYPE.PW_BROWSER_CACHE: int(os.getenv(add_suffix(CONFIG_TYPE.PW_BROWSER_CACHE), "0")), + CONFIG_TYPE.PW_CACHE_EXPIRE: int(os.getenv(add_suffix(CONFIG_TYPE.PW_CACHE_EXPIRE), "5")), + CONFIG_TYPE.PW_CONTROL_SECRET: os.getenv(add_suffix(CONFIG_TYPE.PW_CONTROL_SECRET), ""), + CONFIG_TYPE.PW_EMAIL: os.getenv(add_suffix(CONFIG_TYPE.PW_EMAIL), "email@example.com"), + CONFIG_TYPE.PW_GW_PWD: os.getenv(add_suffix(CONFIG_TYPE.PW_GW_PWD), None), + CONFIG_TYPE.PW_HOST: os.getenv(add_suffix(CONFIG_TYPE.PW_HOST), ""), + CONFIG_TYPE.PW_HTTPS: os.getenv(add_suffix(CONFIG_TYPE.PW_HTTPS), "no"), + CONFIG_TYPE.PW_NEG_SOLAR: bool(os.getenv(add_suffix(CONFIG_TYPE.PW_NEG_SOLAR), "yes").lower() == "yes"), + CONFIG_TYPE.PW_PASSWORD: os.getenv(add_suffix(CONFIG_TYPE.PW_PASSWORD), ""), + CONFIG_TYPE.PW_POOL_MAXSIZE: int(os.getenv(add_suffix(CONFIG_TYPE.PW_POOL_MAXSIZE), "15")), + CONFIG_TYPE.PW_PORT: int(os.getenv(add_suffix(CONFIG_TYPE.PW_PORT), "8675")), + CONFIG_TYPE.PW_SITEID: os.getenv(add_suffix(CONFIG_TYPE.PW_SITEID), None), + CONFIG_TYPE.PW_STYLE: os.getenv(add_suffix(CONFIG_TYPE.PW_STYLE), "clear") + ".js", + CONFIG_TYPE.PW_TIMEOUT: int(os.getenv(add_suffix(CONFIG_TYPE.PW_TIMEOUT), "5")), + CONFIG_TYPE.PW_TIMEZONE: os.getenv(add_suffix(CONFIG_TYPE.PW_TIMEZONE), "America/Los_Angeles"), + CONFIG_TYPE.PW_CACHE_FILE: os.getenv(add_suffix(CONFIG_TYPE.PW_CACHE_FILE), "") + } + configs.append(config) + return configs def read_config_file(file_path: Path) -> List[PROXY_CONFIG]: From 6beda4f2148998e163b817aec2ec127d8bb0750c Mon Sep 17 00:00:00 2001 From: Christopher Pitstick Date: Sun, 12 Jan 2025 16:44:31 -0500 Subject: [PATCH 18/35] Sort --- proxy/server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/proxy/server.py b/proxy/server.py index d779be0..66637fa 100755 --- a/proxy/server.py +++ b/proxy/server.py @@ -937,6 +937,7 @@ def add_suffix(type: CONFIG_TYPE) -> str: CONFIG_TYPE.PW_BIND_ADDRESS: os.getenv(add_suffix(CONFIG_TYPE.PW_BIND_ADDRESS), ""), CONFIG_TYPE.PW_BROWSER_CACHE: int(os.getenv(add_suffix(CONFIG_TYPE.PW_BROWSER_CACHE), "0")), CONFIG_TYPE.PW_CACHE_EXPIRE: int(os.getenv(add_suffix(CONFIG_TYPE.PW_CACHE_EXPIRE), "5")), + CONFIG_TYPE.PW_CACHE_FILE: os.getenv(add_suffix(CONFIG_TYPE.PW_CACHE_FILE), ""), CONFIG_TYPE.PW_CONTROL_SECRET: os.getenv(add_suffix(CONFIG_TYPE.PW_CONTROL_SECRET), ""), CONFIG_TYPE.PW_EMAIL: os.getenv(add_suffix(CONFIG_TYPE.PW_EMAIL), "email@example.com"), CONFIG_TYPE.PW_GW_PWD: os.getenv(add_suffix(CONFIG_TYPE.PW_GW_PWD), None), @@ -949,8 +950,7 @@ def add_suffix(type: CONFIG_TYPE) -> str: CONFIG_TYPE.PW_SITEID: os.getenv(add_suffix(CONFIG_TYPE.PW_SITEID), None), CONFIG_TYPE.PW_STYLE: os.getenv(add_suffix(CONFIG_TYPE.PW_STYLE), "clear") + ".js", CONFIG_TYPE.PW_TIMEOUT: int(os.getenv(add_suffix(CONFIG_TYPE.PW_TIMEOUT), "5")), - CONFIG_TYPE.PW_TIMEZONE: os.getenv(add_suffix(CONFIG_TYPE.PW_TIMEZONE), "America/Los_Angeles"), - CONFIG_TYPE.PW_CACHE_FILE: os.getenv(add_suffix(CONFIG_TYPE.PW_CACHE_FILE), "") + CONFIG_TYPE.PW_TIMEZONE: os.getenv(add_suffix(CONFIG_TYPE.PW_TIMEZONE), "America/Los_Angeles") } configs.append(config) return configs From 5de1c832f03bb6a203cbe7e7de9bfe2206e61789 Mon Sep 17 00:00:00 2001 From: Christopher Pitstick Date: Sun, 12 Jan 2025 18:07:58 -0500 Subject: [PATCH 19/35] Finishing configuration --- proxy/server.py | 56 ++++++++++++++++++++++++------------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/proxy/server.py b/proxy/server.py index 66637fa..43f7bc4 100755 --- a/proxy/server.py +++ b/proxy/server.py @@ -49,7 +49,7 @@ from enum import StrEnum, auto from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from typing import Any, Dict, Final, Set, List, Tuple +from typing import Any, Dict, Final, Optional, Set, List, Tuple from urllib.parse import parse_qs, urlparse from pathlib import Path @@ -911,12 +911,9 @@ def check_for_environmental_pw_configs() -> List[str]: while suffixes_to_check: current_suffix = suffixes_to_check.pop() - for config in CONFIG_TYPE: - env_var = f"{config.value}{current_suffix}" - if env_var in os.environ: - actual_configs.append(current_suffix) - break - + test_suffix = f"_{current_suffix}" if current_suffix.isnumeric() else current_suffix + if any(f"{config.value}{test_suffix}" in os.environ for config in CONFIG_TYPE): + actual_configs.append(test_suffix) if current_suffix.isnumeric(): next_suffix = str(int(current_suffix) + 1) if next_suffix not in suffixes_to_check: @@ -929,28 +926,31 @@ def read_env_configs() -> List[PROXY_CONFIG]: suffixes = check_for_environmental_pw_configs() configs: List[PROXY_CONFIG] = [] for s in suffixes: - def add_suffix(type: CONFIG_TYPE) -> str: - return f"{str(type)}{s}" + def get_env_value(config_type: CONFIG_TYPE, default: str | None) -> str | None: + """Helper function to construct environment variable names and retrieve their values.""" + env_var = f"{config_type.value}{s}" + return os.getenv(env_var, default) + config: PROXY_CONFIG = { - CONFIG_TYPE.PW_AUTH_MODE: os.getenv(add_suffix(CONFIG_TYPE.PW_AUTH_MODE), "cookie"), - CONFIG_TYPE.PW_AUTH_PATH: os.getenv(add_suffix(CONFIG_TYPE.PW_AUTH_PATH), ""), - CONFIG_TYPE.PW_BIND_ADDRESS: os.getenv(add_suffix(CONFIG_TYPE.PW_BIND_ADDRESS), ""), - CONFIG_TYPE.PW_BROWSER_CACHE: int(os.getenv(add_suffix(CONFIG_TYPE.PW_BROWSER_CACHE), "0")), - CONFIG_TYPE.PW_CACHE_EXPIRE: int(os.getenv(add_suffix(CONFIG_TYPE.PW_CACHE_EXPIRE), "5")), - CONFIG_TYPE.PW_CACHE_FILE: os.getenv(add_suffix(CONFIG_TYPE.PW_CACHE_FILE), ""), - CONFIG_TYPE.PW_CONTROL_SECRET: os.getenv(add_suffix(CONFIG_TYPE.PW_CONTROL_SECRET), ""), - CONFIG_TYPE.PW_EMAIL: os.getenv(add_suffix(CONFIG_TYPE.PW_EMAIL), "email@example.com"), - CONFIG_TYPE.PW_GW_PWD: os.getenv(add_suffix(CONFIG_TYPE.PW_GW_PWD), None), - CONFIG_TYPE.PW_HOST: os.getenv(add_suffix(CONFIG_TYPE.PW_HOST), ""), - CONFIG_TYPE.PW_HTTPS: os.getenv(add_suffix(CONFIG_TYPE.PW_HTTPS), "no"), - CONFIG_TYPE.PW_NEG_SOLAR: bool(os.getenv(add_suffix(CONFIG_TYPE.PW_NEG_SOLAR), "yes").lower() == "yes"), - CONFIG_TYPE.PW_PASSWORD: os.getenv(add_suffix(CONFIG_TYPE.PW_PASSWORD), ""), - CONFIG_TYPE.PW_POOL_MAXSIZE: int(os.getenv(add_suffix(CONFIG_TYPE.PW_POOL_MAXSIZE), "15")), - CONFIG_TYPE.PW_PORT: int(os.getenv(add_suffix(CONFIG_TYPE.PW_PORT), "8675")), - CONFIG_TYPE.PW_SITEID: os.getenv(add_suffix(CONFIG_TYPE.PW_SITEID), None), - CONFIG_TYPE.PW_STYLE: os.getenv(add_suffix(CONFIG_TYPE.PW_STYLE), "clear") + ".js", - CONFIG_TYPE.PW_TIMEOUT: int(os.getenv(add_suffix(CONFIG_TYPE.PW_TIMEOUT), "5")), - CONFIG_TYPE.PW_TIMEZONE: os.getenv(add_suffix(CONFIG_TYPE.PW_TIMEZONE), "America/Los_Angeles") + CONFIG_TYPE.PW_AUTH_MODE: get_env_value(CONFIG_TYPE.PW_AUTH_MODE, "cookie"), + CONFIG_TYPE.PW_AUTH_PATH: get_env_value(CONFIG_TYPE.PW_AUTH_PATH, ""), + CONFIG_TYPE.PW_BIND_ADDRESS: get_env_value(CONFIG_TYPE.PW_BIND_ADDRESS, ""), + CONFIG_TYPE.PW_BROWSER_CACHE: int(get_env_value(CONFIG_TYPE.PW_BROWSER_CACHE, "0")), + CONFIG_TYPE.PW_CACHE_EXPIRE: int(get_env_value(CONFIG_TYPE.PW_CACHE_EXPIRE, "5")), + CONFIG_TYPE.PW_CACHE_FILE: get_env_value(CONFIG_TYPE.PW_CACHE_FILE, ""), + CONFIG_TYPE.PW_CONTROL_SECRET: get_env_value(CONFIG_TYPE.PW_CONTROL_SECRET, ""), + CONFIG_TYPE.PW_EMAIL: get_env_value(CONFIG_TYPE.PW_EMAIL, "email@example.com"), + CONFIG_TYPE.PW_GW_PWD: get_env_value(CONFIG_TYPE.PW_GW_PWD, None), + CONFIG_TYPE.PW_HOST: get_env_value(CONFIG_TYPE.PW_HOST, ""), + CONFIG_TYPE.PW_HTTPS: get_env_value(CONFIG_TYPE.PW_HTTPS, "no"), + CONFIG_TYPE.PW_NEG_SOLAR: bool(get_env_value(CONFIG_TYPE.PW_NEG_SOLAR, "yes").lower() == "yes"), + CONFIG_TYPE.PW_PASSWORD: get_env_value(CONFIG_TYPE.PW_PASSWORD, ""), + CONFIG_TYPE.PW_POOL_MAXSIZE: int(get_env_value(CONFIG_TYPE.PW_POOL_MAXSIZE, "15")), + CONFIG_TYPE.PW_PORT: int(get_env_value(CONFIG_TYPE.PW_PORT, "8675")), + CONFIG_TYPE.PW_SITEID: get_env_value(CONFIG_TYPE.PW_SITEID, None), + CONFIG_TYPE.PW_STYLE: get_env_value(CONFIG_TYPE.PW_STYLE, "clear") + ".js", + CONFIG_TYPE.PW_TIMEOUT: int(get_env_value(CONFIG_TYPE.PW_TIMEOUT, "5")), + CONFIG_TYPE.PW_TIMEZONE: get_env_value(CONFIG_TYPE.PW_TIMEZONE, "America/Los_Angeles") } configs.append(config) return configs From b48c26cfc6f06a8d7a03684722578dbe62ce67d3 Mon Sep 17 00:00:00 2001 From: Christopher Pitstick Date: Sun, 12 Jan 2025 18:27:02 -0500 Subject: [PATCH 20/35] Minor fixes --- proxy/server.py | 177 +++--------------------------------------------- 1 file changed, 8 insertions(+), 169 deletions(-) diff --git a/proxy/server.py b/proxy/server.py index 43f7bc4..114f830 100755 --- a/proxy/server.py +++ b/proxy/server.py @@ -112,11 +112,6 @@ def sig_term_handle(signum, frame): pypowerwall.set_debug(True) -class CONFIGURATION_SOURCE(StrEnum): - ENVIRONMENT_VARIABLES = auto() - CONFIGURATION_FILE = auto() - - class CONFIG_TYPE(StrEnum): """_summary_ @@ -942,7 +937,7 @@ def get_env_value(config_type: CONFIG_TYPE, default: str | None) -> str | None: CONFIG_TYPE.PW_EMAIL: get_env_value(CONFIG_TYPE.PW_EMAIL, "email@example.com"), CONFIG_TYPE.PW_GW_PWD: get_env_value(CONFIG_TYPE.PW_GW_PWD, None), CONFIG_TYPE.PW_HOST: get_env_value(CONFIG_TYPE.PW_HOST, ""), - CONFIG_TYPE.PW_HTTPS: get_env_value(CONFIG_TYPE.PW_HTTPS, "no"), + CONFIG_TYPE.PW_HTTPS: get_env_value(CONFIG_TYPE.PW_HTTPS, "no").lower(), CONFIG_TYPE.PW_NEG_SOLAR: bool(get_env_value(CONFIG_TYPE.PW_NEG_SOLAR, "yes").lower() == "yes"), CONFIG_TYPE.PW_PASSWORD: get_env_value(CONFIG_TYPE.PW_PASSWORD, ""), CONFIG_TYPE.PW_POOL_MAXSIZE: int(get_env_value(CONFIG_TYPE.PW_POOL_MAXSIZE, "15")), @@ -956,175 +951,19 @@ def get_env_value(config_type: CONFIG_TYPE, default: str | None) -> str | None: return configs -def read_config_file(file_path: Path) -> List[PROXY_CONFIG]: - configs: List[PROXY_CONFIG] = [] - current_config: PROXY_CONFIG = {} - - # Read the file and parse lines - try: - with file_path.open('r') as file: - for line in file: - # Ignore comments and empty lines - line = line.strip() - if not line or line.startswith("#"): - continue - - # Detect new configuration block - if line.startswith("[Powerwall"): - if current_config: - configs.append(current_config) - current_config = {} - - # Split key and value - elif "=" in line: - key, value = line.split("=", 1) - def aggressive_strip(v: str) -> str: - return v.strip().strip("'").strip('"') - key, value = aggressive_strip(key), aggressive_strip(value) - - # Ensure the key is valid - if not hasattr(CONFIG_TYPE, key): - print(f"Warning: Invalid configuration key '{key}' ignored.") - continue - - # Handle boolean values - if value.lower() in ["yes", "no"]: - value = value.lower() == "yes" - - # Assign to current configuration dictionary - current_config[key] = value - - # Add the last configuration block - if current_config: - configs.append(current_config) - except FileNotFoundError: - print(f"Configuration file '{file_path}' not found.") - -import yaml - -#WIP, loading configuration as yaml. - - -# powerwalls: -# powerwall_1: # Configuration for Powerwall 1 -# PW_AUTH_MODE: "cookie" # Authentication mode, default is "cookie" -# PW_AUTH_PATH: "" # Path for authentication, default is empty -# PW_BIND_ADDRESS: "" # Address to bind, default is empty -# PW_BROWSER_CACHE: 0 # Enable browser cache: 0 = disabled, 1 = enabled -# PW_CACHE_EXPIRE: 5 # Cache expiration in minutes -# PW_CONTROL_SECRET: "" # Secret key for secure control -# PW_EMAIL: "email1@example.com" # Email address for notifications -# PW_GW_PWD: null # Gateway password (if required) -# PW_HOST: "192.168.1.10" # Host address of the Tesla Powerwall -# PW_HTTPS: "no" # Use HTTPS (yes/no) -# PW_NEG_SOLAR: true # Allow negative solar values (true/false) -# PW_PASSWORD: "" # Password for authentication -# PW_POOL_MAXSIZE: 15 # Maximum size of the connection pool -# PW_PORT: 8675 # Port number for the connection -# PW_SITEID: "site_1" # Site ID (if required) -# PW_STYLE: "clear.js" # JavaScript file for UI style -# PW_TIMEOUT: 5 # Connection timeout in seconds -# PW_TIMEZONE: "America/Los_Angeles" # Default timezone -# PW_CACHE_FILE: "" # Path to cache file - -# powerwall_2: # Configuration for Powerwall 2 -# PW_AUTH_MODE: "cookie" -# PW_AUTH_PATH: "" -# PW_BIND_ADDRESS: "" -# PW_BROWSER_CACHE: 0 -# PW_CACHE_EXPIRE: 5 -# PW_CONTROL_SECRET: "" -# PW_EMAIL: "email2@example.com" -# PW_GW_PWD: null -# PW_HOST: "192.168.1.11" -# PW_HTTPS: "no" -# PW_NEG_SOLAR: true -# PW_PASSWORD: "" -# PW_POOL_MAXSIZE: 15 -# PW_PORT: 8675 -# PW_SITEID: "site_2" -# PW_STYLE: "clear.js" -# PW_TIMEOUT: 5 -# PW_TIMEZONE: "America/New_York" -# PW_CACHE_FILE: "" - -# powerwall_3: # Configuration for Powerwall 3 -# PW_AUTH_MODE: "cookie" -# PW_AUTH_PATH: "" -# PW_BIND_ADDRESS: "" -# PW_BROWSER_CACHE: 0 -# PW_CACHE_EXPIRE: 5 -# PW_CONTROL_SECRET: "" -# PW_EMAIL: "email3@example.com" -# PW_GW_PWD: null -# PW_HOST: "192.168.1.12" -# PW_HTTPS: "no" -# PW_NEG_SOLAR: true -# PW_PASSWORD: "" -# PW_POOL_MAXSIZE: 15 -# PW_PORT: 8675 -# PW_SITEID: "site_3" -# PW_STYLE: "clear.js" -# PW_TIMEOUT: 5 -# PW_TIMEZONE: "Europe/London" -# PW_CACHE_FILE: "" - -def load_powerwall_config(file_path): - """ - Loads a YAML configuration file with multiple Powerwall configurations - into a list of dictionaries. - - Args: - file_path (str): Path to the YAML configuration file. - - Returns: - list: A list of dictionaries, where each dictionary represents a Powerwall configuration. - """ - try: - with open(file_path, 'r') as file: - # Load the YAML data - config = yaml.safe_load(file) - - # Convert the named sections to a list of dictionaries - powerwalls = config.get('powerwalls', {}) - return [ - {"name": name, **details} - for name, details in powerwalls.items() - ] - except FileNotFoundError: - print(f"Error: File '{file_path}' not found.") - return [] - except yaml.YAMLError as e: - print(f"Error parsing YAML file: {e}") - return [] - -# Example usage -if __name__ == "__main__": - file_path = "powerwall_config.yaml" # Replace with your YAML file path - powerwall_list = load_powerwall_config(file_path) - for powerwall in powerwall_list: - print(powerwall) - - def build_configuration() -> List[PROXY_CONFIG]: COOKIE_SUFFIX: Final[str] = "path=/;SameSite=None;Secure;" - configuration_source: Final[CONFIGURATION_SOURCE] = CONFIGURATION_SOURCE(os.getenv("PW_CONFIGURATION_SOURCE", CONFIGURATION_SOURCE.ENVIRONMENT_VARIABLES)) - configs: List[PROXY_CONFIG] = [] - - if configuration_source == CONFIGURATION_SOURCE.ENVIRONMENT_VARIABLES: - configs.append(read_env_config()) - elif configuration_source == CONFIGURATION_SOURCE.CONFIGURATION_FILE: - configs.extend(read_config_file(Path("pypowerwall.env"))) - else: - log.error("Configuration source misconfigured. This should never happen.") + configs = read_env_configs() + if len(configs) == 0: + log.error("No TED configurations found. This should never happen. Proxy cannot start.") exit(0) for config in configs: # HTTP/S configuration - if config[CONFIG_TYPE.PW_HTTPS].lower() == "yes": + if config[CONFIG_TYPE.PW_HTTPS] == "yes": config[CONFIG_TYPE.PW_COOKIE_SUFFIX] = COOKIE_SUFFIX config[CONFIG_TYPE.PW_HTTP_TYPE] = "HTTPS" - elif config[CONFIG_TYPE.PW_HTTPS].lower() == "http": + elif config[CONFIG_TYPE.PW_HTTPS] == "http": config[CONFIG_TYPE.PW_COOKIE_SUFFIX] = COOKIE_SUFFIX config[CONFIG_TYPE.PW_HTTP_TYPE] = "HTTP" else: @@ -1135,7 +974,7 @@ def build_configuration() -> List[PROXY_CONFIG]: config[CONFIG_TYPE.PW_CACHE_FILE] = os.path.join(config[CONFIG_TYPE.PW_AUTH_PATH], ".powerwall") if config[CONFIG_TYPE.PW_AUTH_PATH] else ".powerwall" # Check for cache expire time limit below 5s - if config['PW_CACHE_EXPIRE'] < 5: + if config[CONFIG_TYPE.PW_CACHE_EXPIRE] < 5: log.warning(f"Cache expiration set below 5s for host:port={config[CONFIG_TYPE.PW_HOST]}:{config[CONFIG_TYPE.PW_PORT]} (PW_CACHE_EXPIRE={config[CONFIG_TYPE.PW_CACHE_EXPIRE]})") return configs @@ -1209,7 +1048,7 @@ def main() -> None: # Start all server threads for config, server in zip(configs, servers): log.info( - f"pyPowerwall [{pypowerwall.version}] Proxy Server [{BUILD}] - {config[CONFIG_TYPE.PW_HTTP_TYPE]} Port {config['PW_PORT']}{' - DEBUG' if config[CONFIG_TYPE.PW_DEBUG] else ''}" + f"pyPowerwall [{pypowerwall.version}] Proxy Server [{BUILD}] - {config[CONFIG_TYPE.PW_HTTP_TYPE]} Port {config[CONFIG_TYPE.PW_PORT]}{' - DEBUG' if SERVER_DEBUG else ''}" ) log.info("pyPowerwall Proxy Started\n") server.start() From ae196cb944aaeb2e520a040c315f36a5c620d79f Mon Sep 17 00:00:00 2001 From: Christopher Pitstick Date: Sun, 12 Jan 2025 19:54:58 -0500 Subject: [PATCH 21/35] Fixing proxystats --- proxy/server.py | 182 +++++++++++++++++++++++------------------------- 1 file changed, 88 insertions(+), 94 deletions(-) diff --git a/proxy/server.py b/proxy/server.py index 114f830..1fe10c5 100755 --- a/proxy/server.py +++ b/proxy/server.py @@ -44,14 +44,13 @@ import signal import ssl import sys -import time import threading +import time from enum import StrEnum, auto from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from typing import Any, Dict, Final, Optional, Set, List, Tuple +from typing import Dict, Final, List, Set, Tuple from urllib.parse import parse_qs, urlparse -from pathlib import Path from transform import get_static, inject_js @@ -197,12 +196,12 @@ def configure_pw_control(pw: pypowerwall.Powerwall, configuration: PROXY_CONFIG) else: pw_control = pypowerwall.Powerwall( "", - configuration['PW_PASSWORD'], - configuration['PW_EMAIL'], - siteid=configuration['PW_SITEID'], - authpath=configuration['PW_AUTH_PATH'], - authmode=configuration['PW_AUTH_MODE'], - cachefile=configuration['PW_CACHE_FILE'], + configuration[CONFIG_TYPE.PW_PASSWORD], + configuration[CONFIG_TYPE.PW_EMAIL], + siteid=configuration[CONFIG_TYPE.PW_SITEID], + authpath=configuration[CONFIG_TYPE.PW_AUTH_PATH], + authmode=configuration[CONFIG_TYPE.PW_AUTH_MODE], + cachefile=configuration[CONFIG_TYPE.PW_CACHE_FILE], auto_select=True ) except Exception as e: @@ -283,7 +282,7 @@ def __init__(self, *args, configuration: PROXY_CONFIG, pw: pypowerwall.Powerwall super().__init__(*args, **kwargs) def log_message(self, log_format, *args): - if self.configuration[CONFIG_TYPE.PW_DEBUG]: + if SERVER_DEBUG: log.debug(f"{self.address_string()} {log_format % args}") def address_string(self): @@ -291,7 +290,7 @@ def address_string(self): hostaddr, hostport = self.client_address[:2] return hostaddr - def send_json_response(self, data, status_code=HTTPStatus.OK, content_type='application/json') -> bool: + def send_json_response(self, data, status_code=HTTPStatus.OK, content_type='application/json') -> str: response = json.dumps(data) try: self.send_response(status_code) @@ -304,7 +303,7 @@ def send_json_response(self, data, status_code=HTTPStatus.OK, content_type='appl except Exception as exc: log.debug(f"Error sending response: {exc}") self.proxystats[PROXY_STATS_TYPE.ERRORS] += 1 - return False + return response def handle_control_post(self) -> bool: @@ -389,7 +388,6 @@ def do_POST(self): def do_GET(self): """Handle GET requests.""" - self.proxystats[PROXY_STATS_TYPE.GETS] += 1 parsed_path = urlparse(self.path) path = parsed_path.path @@ -415,54 +413,53 @@ def do_GET(self): '/help': self.handle_help, '/api/troubleshooting/problems': self.handle_problems, } - + + result: str = "" if path in path_handlers: - path_handlers[path]() + result = path_handlers[path]() elif path in DISABLED: - self.send_json_response( + result = self.send_json_response( {"status": "404 Response - API Disabled"}, status_code=HTTPStatus.NOT_FOUND ) elif path in ALLOWLIST: - self.handle_allowlist(path) + result = self.handle_allowlist(path) elif path.startswith('/tedapi'): - self.handle_tedapi(path) + result = self.handle_tedapi(path) elif path.startswith('/cloud'): - self.handle_cloud(path) + result = self.handle_cloud(path) elif path.startswith('/fleetapi'): - self.handle_fleetapi(path) + result = self.handle_fleetapi(path) elif self.path.startswith('/control/reserve'): # Current battery reserve level if not self.pw_control: message = '{"error": "Control Commands Disabled - Set PW_CONTROL_SECRET to enable"}' else: message = '{"reserve": %s}' % self.pw_control.get_reserve() - self.send_json_response(json.loads(message)) + result = self.send_json_response(json.loads(message)) elif self.path.startswith('/control/mode'): # Current operating mode if not self.pw_control: message = '{"error": "Control Commands Disabled - Set PW_CONTROL_SECRET to enable"}' else: message = '{"mode": "%s"}' % self.pw_control.get_mode() - self.send_json_response(json.loads(message)) + result = self.send_json_response(json.loads(message)) + else: + result = self.handle_static_content() + + if result is None or result == "": + self.proxystats[PROXY_STATS_TYPE.TIMEOUT] += 1 + elif result == "ERROR!": + self.proxystats[PROXY_STATS_TYPE.ERRORS] += 1 else: - self.handle_static_content() - - # # Count - # if message is None: - # proxystats[PROXY_STATS_TYPE.TIMEOUT] += 1 - # message = "TIMEOUT!" - # elif message == "ERROR!": - # proxystats[PROXY_STATS_TYPE.ERRORS] += 1 - # message = "ERROR!" - # else: - # proxystats[PROXY_STATS_TYPE.GETS] += 1 - # if self.path in proxystats[PROXY_STATS_TYPE.URI]: - # proxystats[PROXY_STATS_TYPE.URI][self.path] += 1 - # else: - # proxystats[PROXY_STATS_TYPE.URI][self.path] = 1 - - def handle_aggregates(self): + self.proxystats[PROXY_STATS_TYPE.GETS] += 1 + if self.path in self.proxystats[PROXY_STATS_TYPE.URI]: + self.proxystats[PROXY_STATS_TYPE.URI][self.path] += 1 + else: + self.proxystats[PROXY_STATS_TYPE.URI][self.path] = 1 + + + def handle_aggregates(self) -> str: # Meters - JSON aggregates = self.pw.poll('/api/meters/aggregates') if not self.configuration[CONFIG_TYPE.PW_NEG_SOLAR] and aggregates and 'solar' in aggregates: @@ -472,25 +469,25 @@ def handle_aggregates(self): # Shift energy from solar to load if 'load' in aggregates and 'instant_power' in aggregates['load']: aggregates['load']['instant_power'] -= solar['instant_power'] - self.send_json_response(aggregates) + return self.send_json_response(aggregates) - def handle_soe(self) -> bool: + def handle_soe(self) -> str: soe = self.pw.poll('/api/system_status/soe', jsonformat=True) return self.send_json_response(json.loads(soe)) - def handle_soe_scaled(self) -> bool: + def handle_soe_scaled(self) -> str: level = self.pw.level(scale=True) return self.send_json_response({"percentage": level}) - def handle_grid_status(self): + def handle_grid_status(self) -> str: grid_status = self.pw.poll('/api/system_status/grid_status', jsonformat=True) - self.send_json_response(json.loads(grid_status)) + return self.send_json_response(json.loads(grid_status)) - def handle_csv(self): + def handle_csv(self) -> str: # Grid,Home,Solar,Battery,Level - CSV batterylevel = self.pw.level() or 0 grid = self.pw.grid() or 0 @@ -502,20 +499,20 @@ def handle_csv(self): # Shift energy from solar to load home -= solar message = f"{grid:.2f},{home:.2f},{solar:.2f},{battery:.2f},{batterylevel:.2f}\n" - self.send_json_response(message) + return self.send_json_response(message) - def handle_vitals(self): + def handle_vitals(self) -> str: vitals = self.pw.vitals(jsonformat=True) or {} - self.send_json_response(json.loads(vitals)) + return self.send_json_response(json.loads(vitals)) - def handle_strings(self): + def handle_strings(self) -> str: strings = self.pw.strings(jsonformat=True) or {} - self.send_json_response(json.loads(strings)) + return self.send_json_response(json.loads(strings)) - def handle_stats(self): + def handle_stats(self) -> str: self.proxystats.update({ PROXY_STATS_TYPE.TS: int(time.time()), PROXY_STATS_TYPE.UPTIME: str(datetime.timedelta(seconds=(self.proxystats[PROXY_STATS_TYPE.TS] - self.proxystats[PROXY_STATS_TYPE.START]))), @@ -528,10 +525,10 @@ def handle_stats(self): if (self.pw.cloudmode or self.pw.fleetapi) and self.pw.client: self.proxystats[PROXY_STATS_TYPE.SITEID] = self.pw.client.siteid self.proxystats[PROXY_STATS_TYPE.COUNTER] = self.pw.client.counter - self.send_json_response(self.proxystats) + return self.send_json_response(self.proxystats) - def handle_stats_clear(self): + def handle_stats_clear(self) -> str: # Clear Internal Stats log.debug("Clear internal stats") self.proxystats.update({ @@ -540,32 +537,32 @@ def handle_stats_clear(self): PROXY_STATS_TYPE.URI: {}, PROXY_STATS_TYPE.CLEAR: int(time.time()), }) - self.send_json_response(self.proxystats) + return self.send_json_response(self.proxystats) - def handle_temps(self): + def handle_temps(self) -> str: temps = self.pw.temps(jsonformat=True) or {} - self.send_json_response(json.loads(temps)) + return self.send_json_response(json.loads(temps)) - def handle_temps_pw(self): + def handle_temps_pw(self) -> str: temps = self.pw.temps() or {} pw_temp = {f"PW{idx}_temp": temp for idx, temp in enumerate(temps.values(), 1)} - self.send_json_response(pw_temp) + return self.send_json_response(pw_temp) - def handle_alerts(self): + def handle_alerts(self) -> str: alerts = self.pw.alerts(jsonformat=True) or [] - self.send_json_response(alerts) + return self.send_json_response(alerts) - def handle_alerts_pw(self): + def handle_alerts_pw(self) -> str: alerts = self.pw.alerts() or [] pw_alerts = {alert: 1 for alert in alerts} - self.send_json_response(pw_alerts) + return self.send_json_response(pw_alerts) - def handle_freq(self): + def handle_freq(self) -> str: fcv = {} system_status = self.pw.system_status() or {} blocks = system_status.get("battery_blocks", []) @@ -595,10 +592,10 @@ def handle_freq(self): if device.startswith(('TESYNC', 'TEMSA')): fcv.update({key: value for key, value in data.items() if key.startswith(('ISLAND', 'METER'))}) fcv["grid_status"] = self.pw.grid_status(type="numeric") - self.send_json_response(fcv) + return self.send_json_response(fcv) - def handle_pod(self): + def handle_pod(self) -> str: # Powerwall Battery Data pod = {} # Get Individual Powerwall Battery Data @@ -669,16 +666,16 @@ def handle_pod(self): "time_remaining_hours": self.pw.get_time_remaining(), "backup_reserve_percent": self.pw.get_reserve() }) - self.send_json_response(pod) + return self.send_json_response(pod) - def handle_version(self): + def handle_version(self) -> str: version = self.pw.version() r = {"version": "SolarOnly", "vint": 0} if version is None else {"version": version, "vint": parse_version(version)} - self.send_json_response(r) + return self.send_json_response(r) - def handle_help(self): + def handle_help(self) -> str: self.send_response(HTTPStatus.OK) self.send_header('Content-type', 'text/html') self.end_headers() @@ -747,20 +744,19 @@ def handle_help(self): """ message += "
{key}{display_value}
\n" message += f'\n

Page refresh: {str(datetime.datetime.fromtimestamp(time.time()))}

\n\n' - self.wfile.write(message.encode(UTF_8)) + return self.wfile.write(message.encode(UTF_8)) - def handle_problems(self): + def handle_problems(self) -> str: self.send_json_response({"problems": []}) - def handle_allowlist(self, path) -> bool: + def handle_allowlist(self, path) -> str: response = self.pw.poll(path, jsonformat=True) return self.send_json_response(json.loads(response)) - def handle_tedapi(self, path): + def handle_tedapi(self, path) -> str: if not self.pw.tedapi: - self.send_json_response({"error": "TEDAPI not enabled"}, status_code=HTTPStatus.BAD_REQUEST) - return + return self.send_json_response({"error": "TEDAPI not enabled"}, status_code=HTTPStatus.BAD_REQUEST) commands = { '/tedapi/config': self.pw.tedapi.get_config, @@ -771,18 +767,17 @@ def handle_tedapi(self, path): } command = commands.get(path) if command: - self.send_json_response(command()) - else: - self.send_json_response( - {"error": "Use /tedapi/config, /tedapi/status, /tedapi/components, /tedapi/battery, /tedapi/controller"}, - status_code=HTTPStatus.BAD_REQUEST - ) + return self.send_json_response(command()) + + return self.send_json_response( + {"error": "Use /tedapi/config, /tedapi/status, /tedapi/components, /tedapi/battery, /tedapi/controller"}, + status_code=HTTPStatus.BAD_REQUEST + ) - def handle_cloud(self, path): + def handle_cloud(self, path) -> str: if not self.pw.cloudmode or self.pw.fleetapi: - self.send_json_response({"error": "Cloud API not enabled"}, status_code=HTTPStatus.BAD_REQUEST) - return + return self.send_json_response({"error": "Cloud API not enabled"}, status_code=HTTPStatus.BAD_REQUEST) commands = { '/cloud/battery': self.pw.client.get_battery, @@ -791,15 +786,13 @@ def handle_cloud(self, path): } command = commands.get(path) if command: - self.send_json_response(command()) - else: - self.send_json_response({"error": "Use /cloud/battery, /cloud/power, /cloud/config"}, status_code=HTTPStatus.BAD_REQUEST) + return self.send_json_response(command()) + return self.send_json_response({"error": "Use /cloud/battery, /cloud/power, /cloud/config"}, status_code=HTTPStatus.BAD_REQUEST) - def handle_fleetapi(self, path): + def handle_fleetapi(self, path) -> str: if not self.pw.fleetapi: - self.send_json_response({"error": "FleetAPI not enabled"}, status_code=HTTPStatus.BAD_REQUEST) - return + return self.send_json_response({"error": "FleetAPI not enabled"}, status_code=HTTPStatus.BAD_REQUEST) commands = { '/fleetapi/info': self.pw.client.get_site_info, @@ -807,12 +800,12 @@ def handle_fleetapi(self, path): } command = commands.get(path) if command: - self.send_json_response(command()) - else: - self.send_json_response({"error": "Use /fleetapi/info, /fleetapi/status"}, status_code=HTTPStatus.BAD_REQUEST) + return self.send_json_response(command()) + + return self.send_json_response({"error": "Use /fleetapi/info, /fleetapi/status"}, status_code=HTTPStatus.BAD_REQUEST) - def handle_static_content(self): + def handle_static_content(self) -> str: self.proxystats[PROXY_STATS_TYPE.GETS] += 1 self.send_response(HTTPStatus.OK) self.send_header('Content-type', 'text/html') @@ -883,6 +876,7 @@ def handle_static_content(self): log.debug(f"Client disconnected before payload sent [doGET]: {exc}") return log.error(f"Error occured while sending PROXY response to client [doGET]: {exc}") + return content def check_for_environmental_pw_configs() -> List[str]: From 0b88120e7049e208c2267eb179f833439b0454c2 Mon Sep 17 00:00:00 2001 From: Christopher Pitstick Date: Sun, 12 Jan 2025 20:24:03 -0500 Subject: [PATCH 22/35] Startup tweaks --- proxy/server.py | 37 +++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/proxy/server.py b/proxy/server.py index 1fe10c5..d32ff31 100755 --- a/proxy/server.py +++ b/proxy/server.py @@ -60,7 +60,7 @@ BUILD: Final[str] = "t67" UTF_8: Final[str] = "utf-8" -ALLOWLIST = Final[Set[str]] = set([ +ALLOWLIST: Final[Set[str]] = set([ '/api/auth/toggle/supported', '/api/customer', '/api/customer/registration', @@ -89,7 +89,7 @@ '/api/troubleshooting/problems', ]) -DISABLED = Final[Set[str]] = set([ +DISABLED: Final[Set[str]] = set([ '/api/customer/registration', ]) WEB_ROOT: Final[str] = os.path.join(os.path.dirname(__file__), "web") @@ -119,7 +119,6 @@ class CONFIG_TYPE(StrEnum): """ PW_AUTH_MODE = auto() PW_AUTH_PATH = auto() - PW_AUTH_PATH = auto() PW_BIND_ADDRESS = auto() PW_BROWSER_CACHE = auto() PW_CACHE_EXPIRE = auto() @@ -299,10 +298,10 @@ def send_json_response(self, data, status_code=HTTPStatus.OK, content_type='appl self.send_header("Access-Control-Allow-Origin", "*") self.end_headers() if self.wfile.write(response.encode(UTF_8)) > 0: - return True + return response except Exception as exc: log.debug(f"Error sending response: {exc}") - self.proxystats[PROXY_STATS_TYPE.ERRORS] += 1 + self.proxystats[PROXY_STATS_TYPE.ERRORS] = int(self.proxystats[PROXY_STATS_TYPE.ERRORS]) + 1 return response @@ -515,7 +514,7 @@ def handle_strings(self) -> str: def handle_stats(self) -> str: self.proxystats.update({ PROXY_STATS_TYPE.TS: int(time.time()), - PROXY_STATS_TYPE.UPTIME: str(datetime.timedelta(seconds=(self.proxystats[PROXY_STATS_TYPE.TS] - self.proxystats[PROXY_STATS_TYPE.START]))), + PROXY_STATS_TYPE.UPTIME: str(datetime.timedelta(seconds=(float(self.proxystats[PROXY_STATS_TYPE.TS]) - float(self.proxystats[PROXY_STATS_TYPE.START])))), PROXY_STATS_TYPE.MEM: resource.getrusage(resource.RUSAGE_SELF).ru_maxrss, PROXY_STATS_TYPE.SITE_NAME: self.pw.site_name(), PROXY_STATS_TYPE.CLOUDMODE: self.pw.cloudmode, @@ -748,7 +747,7 @@ def handle_help(self) -> str: def handle_problems(self) -> str: - self.send_json_response({"problems": []}) + return self.send_json_response({"problems": []}) def handle_allowlist(self, path) -> str: response = self.pw.poll(path, jsonformat=True) @@ -824,8 +823,8 @@ def handle_static_content(self) -> str: content = content.decode(UTF_8).format( VERSION=status.get("version", ""), HASH=status.get("git_hash", ""), - EMAIL=self.configuration['PW_EMAIL'], - STYLE=self.configuration['PW_STYLE'], + EMAIL=self.configuration[CONFIG_TYPE.PW_EMAIL], + STYLE=self.configuration[CONFIG_TYPE.PW_STYLE], ).encode(UTF_8) else: content, content_type = get_static(WEB_ROOT, self.path) @@ -874,7 +873,7 @@ def handle_static_content(self) -> str: except Exception as exc: if "Broken pipe" in str(exc): log.debug(f"Client disconnected before payload sent [doGET]: {exc}") - return + return content log.error(f"Error occured while sending PROXY response to client [doGET]: {exc}") return content @@ -903,10 +902,12 @@ def check_for_environmental_pw_configs() -> List[str]: test_suffix = f"_{current_suffix}" if current_suffix.isnumeric() else current_suffix if any(f"{config.value}{test_suffix}" in os.environ for config in CONFIG_TYPE): actual_configs.append(test_suffix) + elif current_suffix.isnumeric() and int(current_suffix) > 1: + break if current_suffix.isnumeric(): next_suffix = str(int(current_suffix) + 1) if next_suffix not in suffixes_to_check: - suffixes_to_check.append(next_suffix) + suffixes_to_check.add(next_suffix) return actual_configs @@ -931,13 +932,13 @@ def get_env_value(config_type: CONFIG_TYPE, default: str | None) -> str | None: CONFIG_TYPE.PW_EMAIL: get_env_value(CONFIG_TYPE.PW_EMAIL, "email@example.com"), CONFIG_TYPE.PW_GW_PWD: get_env_value(CONFIG_TYPE.PW_GW_PWD, None), CONFIG_TYPE.PW_HOST: get_env_value(CONFIG_TYPE.PW_HOST, ""), - CONFIG_TYPE.PW_HTTPS: get_env_value(CONFIG_TYPE.PW_HTTPS, "no").lower(), - CONFIG_TYPE.PW_NEG_SOLAR: bool(get_env_value(CONFIG_TYPE.PW_NEG_SOLAR, "yes").lower() == "yes"), + CONFIG_TYPE.PW_HTTPS: (get_env_value(CONFIG_TYPE.PW_HTTPS, "no") or "no").lower(), + CONFIG_TYPE.PW_NEG_SOLAR: bool((get_env_value(CONFIG_TYPE.PW_NEG_SOLAR, "yes") or "yes").lower() == "yes"), CONFIG_TYPE.PW_PASSWORD: get_env_value(CONFIG_TYPE.PW_PASSWORD, ""), CONFIG_TYPE.PW_POOL_MAXSIZE: int(get_env_value(CONFIG_TYPE.PW_POOL_MAXSIZE, "15")), CONFIG_TYPE.PW_PORT: int(get_env_value(CONFIG_TYPE.PW_PORT, "8675")), CONFIG_TYPE.PW_SITEID: get_env_value(CONFIG_TYPE.PW_SITEID, None), - CONFIG_TYPE.PW_STYLE: get_env_value(CONFIG_TYPE.PW_STYLE, "clear") + ".js", + CONFIG_TYPE.PW_STYLE: str(get_env_value(CONFIG_TYPE.PW_STYLE, "clear")) + ".js", CONFIG_TYPE.PW_TIMEOUT: int(get_env_value(CONFIG_TYPE.PW_TIMEOUT, "5")), CONFIG_TYPE.PW_TIMEZONE: get_env_value(CONFIG_TYPE.PW_TIMEZONE, "America/Los_Angeles") } @@ -949,7 +950,7 @@ def build_configuration() -> List[PROXY_CONFIG]: COOKIE_SUFFIX: Final[str] = "path=/;SameSite=None;Secure;" configs = read_env_configs() if len(configs) == 0: - log.error("No TED configurations found. This should never happen. Proxy cannot start.") + log.error("No Tesla Energy Gateway configurations found. This should never happen. Proxy cannot start.") exit(0) for config in configs: @@ -968,7 +969,7 @@ def build_configuration() -> List[PROXY_CONFIG]: config[CONFIG_TYPE.PW_CACHE_FILE] = os.path.join(config[CONFIG_TYPE.PW_AUTH_PATH], ".powerwall") if config[CONFIG_TYPE.PW_AUTH_PATH] else ".powerwall" # Check for cache expire time limit below 5s - if config[CONFIG_TYPE.PW_CACHE_EXPIRE] < 5: + if int(config[CONFIG_TYPE.PW_CACHE_EXPIRE]) < 5: log.warning(f"Cache expiration set below 5s for host:port={config[CONFIG_TYPE.PW_HOST]}:{config[CONFIG_TYPE.PW_PORT]} (PW_CACHE_EXPIRE={config[CONFIG_TYPE.PW_CACHE_EXPIRE]})") return configs @@ -995,7 +996,7 @@ def run_server(host, port, handler, enable_https=False): def main() -> None: - servers: List[Tuple[PROXY_CONFIG, threading.Thread]] = [] + servers: List[threading.Thread] = [] configs = build_configuration() for config in configs: @@ -1025,7 +1026,7 @@ def main() -> None: except (KeyboardInterrupt, SystemExit): sys.exit(0) - pw_control = configure_pw_control(pw) + pw_control = configure_pw_control(pw, config) handler = Handler(configuration=config, pw=pw, pw_control=pw_control) server = threading.Thread( From 900b65208f23d65df089ca57e8bec544e86b66f0 Mon Sep 17 00:00:00 2001 From: Christopher Pitstick Date: Mon, 13 Jan 2025 21:23:21 -0500 Subject: [PATCH 23/35] Fixing startup bugs --- proxy/requirements.txt | 1 + proxy/server.py | 20 +++++++++++++++----- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/proxy/requirements.txt b/proxy/requirements.txt index 33a1a12..f524b70 100644 --- a/proxy/requirements.txt +++ b/proxy/requirements.txt @@ -1,2 +1,3 @@ pypowerwall==0.12.3 bs4==0.0.2 +beautifulsoup4==4.12.3 \ No newline at end of file diff --git a/proxy/server.py b/proxy/server.py index d32ff31..e08971d 100755 --- a/proxy/server.py +++ b/proxy/server.py @@ -46,6 +46,7 @@ import sys import threading import time +import unicodedata from enum import StrEnum, auto from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -57,6 +58,13 @@ import pypowerwall from pypowerwall import parse_version + +def normalize_caseless(text: str) -> str: + return unicodedata.normalize("NFKD", text.casefold()) + +def caseless_equal(left: str, right: str) -> bool: + return normalize_caseless(left) == normalize_caseless(right) + BUILD: Final[str] = "t67" UTF_8: Final[str] = "utf-8" @@ -896,12 +904,14 @@ def check_for_environmental_pw_configs() -> List[str]: """ suffixes_to_check = {"", "1"} actual_configs = [] - + + environment = [normalize_caseless(key) for key in os.environ] while suffixes_to_check: current_suffix = suffixes_to_check.pop() test_suffix = f"_{current_suffix}" if current_suffix.isnumeric() else current_suffix - if any(f"{config.value}{test_suffix}" in os.environ for config in CONFIG_TYPE): - actual_configs.append(test_suffix) + #if any( in (normalize_caseless(key) ) ): + if any(f"{config.value}{test_suffix}" in environment for config in CONFIG_TYPE): + actual_configs.append(test_suffix) elif current_suffix.isnumeric() and int(current_suffix) > 1: break if current_suffix.isnumeric(): @@ -1006,9 +1016,9 @@ def main() -> None: password=config[CONFIG_TYPE.PW_PASSWORD], email=config[CONFIG_TYPE.PW_EMAIL], timezone=config[CONFIG_TYPE.PW_TIMEZONE], - cache_expire=config[CONFIG_TYPE.PW_CACHE_EXPIRE], + pwcacheexpire=config[CONFIG_TYPE.PW_CACHE_EXPIRE], timeout=config[CONFIG_TYPE.PW_TIMEOUT], - pool_maxsize=config[CONFIG_TYPE.PW_POOL_MAXSIZE], + poolmaxsize=config[CONFIG_TYPE.PW_POOL_MAXSIZE], siteid=config[CONFIG_TYPE.PW_SITEID], authpath=config[CONFIG_TYPE.PW_AUTH_PATH], authmode=config[CONFIG_TYPE.PW_AUTH_MODE], From 099141fd809e0037f0e69a41dee53b6ad848dc85 Mon Sep 17 00:00:00 2001 From: Christopher Pitstick Date: Mon, 13 Jan 2025 22:46:54 -0500 Subject: [PATCH 24/35] Working proxy --- proxy/server.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/proxy/server.py b/proxy/server.py index e08971d..d1edb20 100755 --- a/proxy/server.py +++ b/proxy/server.py @@ -227,7 +227,7 @@ def configure_pw_control(pw: pypowerwall.Powerwall, configuration: PROXY_CONFIG) class Handler(BaseHTTPRequestHandler): - def __init__(self, *args, configuration: PROXY_CONFIG, pw: pypowerwall.Powerwall, pw_control: pypowerwall.Powerwall, **kwargs): + def __init__(self, request, client_address, server, configuration: PROXY_CONFIG, pw: pypowerwall.Powerwall, pw_control: pypowerwall.Powerwall): self.configuration = configuration self.pw = pw self.pw_control = pw_control @@ -286,7 +286,7 @@ def __init__(self, *args, configuration: PROXY_CONFIG, pw: pypowerwall.Powerwall proxystats[PROXY_STATS_TYPE.PW3] = pw.tedapi.pw3 log.info(f"TEDAPI Mode Enabled for Device Vitals ({pw.tedapi_mode})") - super().__init__(*args, **kwargs) + super().__init__(request, client_address, server) def log_message(self, log_format, *args): if SERVER_DEBUG: @@ -928,7 +928,7 @@ def read_env_configs() -> List[PROXY_CONFIG]: for s in suffixes: def get_env_value(config_type: CONFIG_TYPE, default: str | None) -> str | None: """Helper function to construct environment variable names and retrieve their values.""" - env_var = f"{config_type.value}{s}" + env_var = f"{config_type.value}{s}".upper() return os.getenv(env_var, default) config: PROXY_CONFIG = { @@ -985,8 +985,8 @@ def build_configuration() -> List[PROXY_CONFIG]: return configs -def run_server(host, port, handler, enable_https=False): - with ThreadingHTTPServer((host, port), handler) as server: +def run_server(host, port, enable_https, configuration: PROXY_CONFIG, pw: pypowerwall.Powerwall, pw_control: pypowerwall.Powerwall): + with ThreadingHTTPServer((host, port), lambda *args: Handler(*args, configuration, pw, pw_control)) as server: if enable_https: log.debug(f"Activating HTTPS on {host}:{port}") server.socket = ssl.wrap_socket( @@ -1037,15 +1037,16 @@ def main() -> None: sys.exit(0) pw_control = configure_pw_control(pw, config) - handler = Handler(configuration=config, pw=pw, pw_control=pw_control) server = threading.Thread( target=run_server, args=( - config[CONFIG_TYPE.PW_BIND_ADDRESS], # Host - config[CONFIG_TYPE.PW_PORT], # Port - handler, # Handler - config[CONFIG_TYPE.PW_HTTPS] == "yes" # HTTPS + config[CONFIG_TYPE.PW_BIND_ADDRESS], # Host + config[CONFIG_TYPE.PW_PORT], # Port + config[CONFIG_TYPE.PW_HTTPS] == "yes", # HTTPS + config, + pw, + pw_control ) ) servers.append(server) From a04be08e293cb0e3870950d41c46cf546bfd7a60 Mon Sep 17 00:00:00 2001 From: Christopher Pitstick Date: Mon, 13 Jan 2025 22:52:15 -0500 Subject: [PATCH 25/35] Removing normalization --- proxy/server.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/proxy/server.py b/proxy/server.py index d1edb20..018ea08 100755 --- a/proxy/server.py +++ b/proxy/server.py @@ -58,13 +58,6 @@ import pypowerwall from pypowerwall import parse_version - -def normalize_caseless(text: str) -> str: - return unicodedata.normalize("NFKD", text.casefold()) - -def caseless_equal(left: str, right: str) -> bool: - return normalize_caseless(left) == normalize_caseless(right) - BUILD: Final[str] = "t67" UTF_8: Final[str] = "utf-8" @@ -904,12 +897,11 @@ def check_for_environmental_pw_configs() -> List[str]: """ suffixes_to_check = {"", "1"} actual_configs = [] - - environment = [normalize_caseless(key) for key in os.environ] + + environment = [key.lower() for key in os.environ] while suffixes_to_check: current_suffix = suffixes_to_check.pop() test_suffix = f"_{current_suffix}" if current_suffix.isnumeric() else current_suffix - #if any( in (normalize_caseless(key) ) ): if any(f"{config.value}{test_suffix}" in environment for config in CONFIG_TYPE): actual_configs.append(test_suffix) elif current_suffix.isnumeric() and int(current_suffix) > 1: From 5751d3195416933e18d52b79bc25b9d837b41df8 Mon Sep 17 00:00:00 2001 From: Christopher Pitstick Date: Tue, 14 Jan 2025 20:25:04 -0500 Subject: [PATCH 26/35] Fix missing config types --- proxy/server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/proxy/server.py b/proxy/server.py index 018ea08..01ff12a 100755 --- a/proxy/server.py +++ b/proxy/server.py @@ -331,7 +331,7 @@ def handle_control_post(self) -> bool: ) return False - if token != self.configuration['PW_CONTROL_SECRET']: + if token != self.configuration[CONFIG_TYPE.PW_CONTROL_SECRET]: self.send_json_response( {"unauthorized": "Control Command Token Invalid"}, status_code=HTTPStatus.UNAUTHORIZED @@ -858,7 +858,7 @@ def handle_static_content(self) -> str: content = b"Error during proxy" content_type = "text/plain" - if self.configuration['PW_BROWSER_CACHE'] > 0 and content_type in ['text/css', 'application/javascript', 'image/png']: + if self.configuration[CONFIG_TYPE.PW_BROWSER_CACHE] > 0 and content_type in ['text/css', 'application/javascript', 'image/png']: self.send_header("Cache-Control", f"max-age={self.configuration[CONFIG_TYPE.PW_BROWSER_CACHE]}") else: self.send_header("Cache-Control", "no-cache, no-store") From d2241b6a819d21486e3ea9ace80f58502bf208ff Mon Sep 17 00:00:00 2001 From: Christopher Pitstick Date: Tue, 14 Jan 2025 22:06:02 -0500 Subject: [PATCH 27/35] Fixing some of the static content --- proxy/server.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/proxy/server.py b/proxy/server.py index 01ff12a..49551f2 100755 --- a/proxy/server.py +++ b/proxy/server.py @@ -821,12 +821,14 @@ def handle_static_content(self) -> str: self.path = "/index.html" content, content_type = get_static(WEB_ROOT, self.path) status = self.pw.status() - content = content.decode(UTF_8).format( - VERSION=status.get("version", ""), - HASH=status.get("git_hash", ""), - EMAIL=self.configuration[CONFIG_TYPE.PW_EMAIL], - STYLE=self.configuration[CONFIG_TYPE.PW_STYLE], - ).encode(UTF_8) + content = content.decode(UTF_8) + # fix the following variables that if they are None, return "" + content = content.replace("{VERSION}", status.get("version", "") or "") + content = content.replace("{HASH}", status.get("git_hash", "") or "") + content = content.replace("{EMAIL}", self.configuration.get(CONFIG_TYPE.PW_EMAIL, "") or "") + content = content.replace("{STYLE}", self.configuration.get(CONFIG_TYPE.PW_STYLE, "") or "") + # convert fcontent back to bytes + content = bytes(content, UTF_8) else: content, content_type = get_static(WEB_ROOT, self.path) @@ -835,7 +837,7 @@ def handle_static_content(self) -> str: # If not found, serve from Powerwall web server elif self.pw.cloudmode or self.pw.fleetapi: log.debug(f"Cloud Mode - File not found: {self.path}") - content = b"Not Found" + content = bytes("Not Found", UTF_8) content_type = "text/plain" else: # Proxy request to Powerwall web server. From 4344252c664bc64e147ec4d2b1fb0b3f0e162586 Mon Sep 17 00:00:00 2001 From: Christopher Pitstick Date: Wed, 15 Jan 2025 00:24:48 -0500 Subject: [PATCH 28/35] Fixes to URL parsing --- proxy/server.py | 167 ++++++++++++++++++++++++------------------------ 1 file changed, 84 insertions(+), 83 deletions(-) diff --git a/proxy/server.py b/proxy/server.py index 49551f2..b1539c9 100755 --- a/proxy/server.py +++ b/proxy/server.py @@ -220,65 +220,11 @@ def configure_pw_control(pw: pypowerwall.Powerwall, configuration: PROXY_CONFIG) class Handler(BaseHTTPRequestHandler): - def __init__(self, request, client_address, server, configuration: PROXY_CONFIG, pw: pypowerwall.Powerwall, pw_control: pypowerwall.Powerwall): + def __init__(self, request, client_address, server, configuration: PROXY_CONFIG, pw: pypowerwall.Powerwall, pw_control: pypowerwall.Powerwall, proxy_stats: PROXY_STATS): self.configuration = configuration self.pw = pw self.pw_control = pw_control - - proxystats: PROXY_STATS = { - PROXY_STATS_TYPE.CF: configuration[CONFIG_TYPE.PW_CACHE_FILE], - PROXY_STATS_TYPE.CLEAR: int(time.time()), - PROXY_STATS_TYPE.CLOUDMODE: False, - PROXY_STATS_TYPE.CONFIG: configuration.copy(), - PROXY_STATS_TYPE.COUNTER: 0, - PROXY_STATS_TYPE.ERRORS: 0, - PROXY_STATS_TYPE.FLEETAPI: False, - PROXY_STATS_TYPE.GETS: 0, - PROXY_STATS_TYPE.MEM: 0, - PROXY_STATS_TYPE.MODE: "Unknown", - PROXY_STATS_TYPE.POSTS: 0, - PROXY_STATS_TYPE.PW3: False, - PROXY_STATS_TYPE.PYPOWERWALL: f"{pypowerwall.version} Proxy {BUILD}", - PROXY_STATS_TYPE.SITE_NAME: "", - PROXY_STATS_TYPE.SITEID: None, - PROXY_STATS_TYPE.START: int(time.time()), - PROXY_STATS_TYPE.TEDAPI_MODE: "off", - PROXY_STATS_TYPE.TEDAPI: False, - PROXY_STATS_TYPE.TIMEOUT: 0, - PROXY_STATS_TYPE.TS: int(time.time()), - PROXY_STATS_TYPE.UPTIME: "", - PROXY_STATS_TYPE.URI: {} - } - self.proxystats = proxystats - - site_name = pw.site_name() or "Unknown" - if pw.cloudmode or pw.fleetapi: - if pw.fleetapi: - proxystats[PROXY_STATS_TYPE.MODE] = "FleetAPI" - log.info("pyPowerwall Proxy Server - FleetAPI Mode") - else: - proxystats[PROXY_STATS_TYPE.MODE] = "Cloud" - log.info("pyPowerwall Proxy Server - Cloud Mode") - log.info(f"Connected to Site ID {pw.client.siteid} ({site_name.strip()})") - if configuration[CONFIG_TYPE.PW_SITEID] is not None and configuration[CONFIG_TYPE.PW_SITEID] != str(pw.client.siteid): - log.info(f"Switch to Site ID {configuration[CONFIG_TYPE.PW_SITEID]}") - if not pw.client.change_site(configuration[CONFIG_TYPE.PW_SITEID]): - log.error("Fatal Error: Unable to connect. Please fix config and restart.") - while True: - try: - time.sleep(5) # Infinite loop to keep container running - except (KeyboardInterrupt, SystemExit): - sys.exit(0) - else: - proxystats[PROXY_STATS_TYPE.MODE] = "Local" - log.info("pyPowerwall Proxy Server - Local Mode") - log.info(f"Connected to Energy Gateway {configuration[CONFIG_TYPE.PW_HOST]} ({site_name.strip()})") - if pw.tedapi: - proxystats[PROXY_STATS_TYPE.TEDAPI] = True - proxystats[PROXY_STATS_TYPE.TEDAPI_MODE] = pw.tedapi_mode - proxystats[PROXY_STATS_TYPE.PW3] = pw.tedapi.pw3 - log.info(f"TEDAPI Mode Enabled for Device Vitals ({pw.tedapi_mode})") - + self.proxystats = proxy_stats super().__init__(request, client_address, server) def log_message(self, log_format, *args): @@ -306,7 +252,7 @@ def send_json_response(self, data, status_code=HTTPStatus.OK, content_type='appl return response - def handle_control_post(self) -> bool: + def handle_control_post(self, self_path) -> bool: """Handle control POST requests.""" if not self.pw_control: self.proxystats[PROXY_STATS_TYPE.ERRORS] += 1 @@ -317,7 +263,7 @@ def handle_control_post(self) -> bool: return False try: - action = urlparse(self.path).path.split('/')[2] + action = urlparse(self_path).path.split('/')[2] content_length = int(self.headers.get('Content-Length', 0)) post_data = self.rfile.read(content_length) query_params = parse_qs(post_data.decode(UTF_8)) @@ -377,7 +323,7 @@ def handle_control_post(self) -> bool: def do_POST(self): """Handle POST requests.""" if self.path.startswith('/control'): - stat = PROXY_STATS_TYPE.POSTS if self.handle_control_post() else PROXY_STATS_TYPE.ERRORS + stat = PROXY_STATS_TYPE.POSTS if self.handle_control_post(self.path) else PROXY_STATS_TYPE.ERRORS self.proxystats[stat] += 1 else: self.send_json_response( @@ -388,8 +334,7 @@ def do_POST(self): def do_GET(self): """Handle GET requests.""" - parsed_path = urlparse(self.path) - path = parsed_path.path + path = self.path # Map paths to handler functions path_handlers = { @@ -413,7 +358,7 @@ def do_GET(self): '/help': self.handle_help, '/api/troubleshooting/problems': self.handle_problems, } - + result: str = "" if path in path_handlers: result = path_handlers[path]() @@ -430,14 +375,14 @@ def do_GET(self): result = self.handle_cloud(path) elif path.startswith('/fleetapi'): result = self.handle_fleetapi(path) - elif self.path.startswith('/control/reserve'): + elif path.startswith('/control/reserve'): # Current battery reserve level if not self.pw_control: message = '{"error": "Control Commands Disabled - Set PW_CONTROL_SECRET to enable"}' else: message = '{"reserve": %s}' % self.pw_control.get_reserve() result = self.send_json_response(json.loads(message)) - elif self.path.startswith('/control/mode'): + elif path.startswith('/control/mode'): # Current operating mode if not self.pw_control: message = '{"error": "Control Commands Disabled - Set PW_CONTROL_SECRET to enable"}' @@ -445,18 +390,18 @@ def do_GET(self): message = '{"mode": "%s"}' % self.pw_control.get_mode() result = self.send_json_response(json.loads(message)) else: - result = self.handle_static_content() - + result = self.handle_static_content(path) + if result is None or result == "": self.proxystats[PROXY_STATS_TYPE.TIMEOUT] += 1 elif result == "ERROR!": self.proxystats[PROXY_STATS_TYPE.ERRORS] += 1 else: self.proxystats[PROXY_STATS_TYPE.GETS] += 1 - if self.path in self.proxystats[PROXY_STATS_TYPE.URI]: - self.proxystats[PROXY_STATS_TYPE.URI][self.path] += 1 + if path in self.proxystats[PROXY_STATS_TYPE.URI]: + self.proxystats[PROXY_STATS_TYPE.URI][path] += 1 else: - self.proxystats[PROXY_STATS_TYPE.URI][self.path] = 1 + self.proxystats[PROXY_STATS_TYPE.URI][path] = 1 def handle_aggregates(self) -> str: @@ -805,7 +750,7 @@ def handle_fleetapi(self, path) -> str: return self.send_json_response({"error": "Use /fleetapi/info, /fleetapi/status"}, status_code=HTTPStatus.BAD_REQUEST) - def handle_static_content(self) -> str: + def handle_static_content(self, path) -> str: self.proxystats[PROXY_STATS_TYPE.GETS] += 1 self.send_response(HTTPStatus.OK) self.send_header('Content-type', 'text/html') @@ -817,9 +762,9 @@ def handle_static_content(self) -> str: self.send_header("Set-Cookie", f"AuthCookie={auth['AuthCookie']};{self.configuration[CONFIG_TYPE.PW_COOKIE_SUFFIX]}") self.send_header("Set-Cookie", f"UserRecord={auth['UserRecord']};{self.configuration[CONFIG_TYPE.PW_COOKIE_SUFFIX]}") - if self.path == "/" or self.path == "": - self.path = "/index.html" - content, content_type = get_static(WEB_ROOT, self.path) + if path == "/" or path == "": + path = "/index.html" + content, content_type = get_static(WEB_ROOT, path) status = self.pw.status() content = content.decode(UTF_8) # fix the following variables that if they are None, return "" @@ -830,19 +775,19 @@ def handle_static_content(self) -> str: # convert fcontent back to bytes content = bytes(content, UTF_8) else: - content, content_type = get_static(WEB_ROOT, self.path) + content, content_type = get_static(WEB_ROOT, path) if content: - log.debug("Served from local web root: {} type {}".format(self.path, content_type)) + log.debug("Served from local web root: {} type {}".format(path, content_type)) # If not found, serve from Powerwall web server elif self.pw.cloudmode or self.pw.fleetapi: - log.debug(f"Cloud Mode - File not found: {self.path}") + log.debug(f"Cloud Mode - File not found: {path}") content = bytes("Not Found", UTF_8) content_type = "text/plain" else: # Proxy request to Powerwall web server. - pw_url = f"https://{self.pw.host}/{self.path.lstrip('/')}" - log.debug("Proxy request to: %s", pw_url) + pw_url = f"https://{self.pw.host}/{path.lstrip('/')}" + log.debug(f"Proxy request to: {pw_url}") try: session = self.pw.client.session response = session.get( @@ -865,7 +810,7 @@ def handle_static_content(self) -> str: else: self.send_header("Cache-Control", "no-cache, no-store") - if self.path.split('?')[0] == "/": + if path.split('?')[0] == "/": if os.path.exists(os.path.join(WEB_ROOT, self.configuration[CONFIG_TYPE.PW_STYLE])): content = bytes(inject_js(content, self.configuration[CONFIG_TYPE.PW_STYLE]), UTF_8) @@ -885,8 +830,8 @@ def check_for_environmental_pw_configs() -> List[str]: """ Checks for environment variables with specific suffix patterns and returns the list of matching suffixes. - This function iterates over predefined suffixes (starting with an empty string and "1") to check if any - configuration-related environment variables are defined. If a match is found, the suffix is added to the + This function iterates over predefined suffixes (starting with an empty string and "1") to check if any + configuration-related environment variables are defined. If a match is found, the suffix is added to the result list. For numeric suffixes, the function dynamically generates and checks the next numeric suffix. Returns: @@ -905,7 +850,7 @@ def check_for_environmental_pw_configs() -> List[str]: current_suffix = suffixes_to_check.pop() test_suffix = f"_{current_suffix}" if current_suffix.isnumeric() else current_suffix if any(f"{config.value}{test_suffix}" in environment for config in CONFIG_TYPE): - actual_configs.append(test_suffix) + actual_configs.append(test_suffix) elif current_suffix.isnumeric() and int(current_suffix) > 1: break if current_suffix.isnumeric(): @@ -980,7 +925,35 @@ def build_configuration() -> List[PROXY_CONFIG]: def run_server(host, port, enable_https, configuration: PROXY_CONFIG, pw: pypowerwall.Powerwall, pw_control: pypowerwall.Powerwall): - with ThreadingHTTPServer((host, port), lambda *args: Handler(*args, configuration, pw, pw_control)) as server: + proxystats: PROXY_STATS = { + PROXY_STATS_TYPE.CF: configuration[CONFIG_TYPE.PW_CACHE_FILE], + PROXY_STATS_TYPE.CLEAR: int(time.time()), + PROXY_STATS_TYPE.CLOUDMODE: False, + PROXY_STATS_TYPE.CONFIG: configuration.copy(), + PROXY_STATS_TYPE.COUNTER: 0, + PROXY_STATS_TYPE.ERRORS: 0, + PROXY_STATS_TYPE.FLEETAPI: False, + PROXY_STATS_TYPE.GETS: 0, + PROXY_STATS_TYPE.MEM: 0, + PROXY_STATS_TYPE.MODE: "Unknown", + PROXY_STATS_TYPE.POSTS: 0, + PROXY_STATS_TYPE.PW3: False, + PROXY_STATS_TYPE.PYPOWERWALL: f"{pypowerwall.version} Proxy {BUILD}", + PROXY_STATS_TYPE.SITE_NAME: "", + PROXY_STATS_TYPE.SITEID: None, + PROXY_STATS_TYPE.START: int(time.time()), + PROXY_STATS_TYPE.TEDAPI_MODE: "off", + PROXY_STATS_TYPE.TEDAPI: False, + PROXY_STATS_TYPE.TIMEOUT: 0, + PROXY_STATS_TYPE.TS: int(time.time()), + PROXY_STATS_TYPE.UPTIME: "", + PROXY_STATS_TYPE.URI: {} + } + + def handler_factory(*args, **kwargs): + return Handler(*args, configuration=configuration, pw=pw, pw_control=pw_control, proxy_stats=proxystats, **kwargs) + + with ThreadingHTTPServer((host, port), handler_factory) as server: if enable_https: log.debug(f"Activating HTTPS on {host}:{port}") server.socket = ssl.wrap_socket( @@ -993,6 +966,34 @@ def run_server(host, port, enable_https, configuration: PROXY_CONFIG, pw: pypowe ) try: log.info(f"Starting server on {host}:{port}") + site_name = pw.site_name() or "Unknown" + if pw.cloudmode or pw.fleetapi: + if pw.fleetapi: + proxystats[PROXY_STATS_TYPE.MODE] = "FleetAPI" + log.info("pyPowerwall Proxy Server - FleetAPI Mode") + else: + proxystats[PROXY_STATS_TYPE.MODE] = "Cloud" + log.info("pyPowerwall Proxy Server - Cloud Mode") + log.info(f"Connected to Site ID {pw.client.siteid} ({site_name.strip()})") + if configuration[CONFIG_TYPE.PW_SITEID] is not None and configuration[CONFIG_TYPE.PW_SITEID] != str(pw.client.siteid): + log.info(f"Switch to Site ID {configuration[CONFIG_TYPE.PW_SITEID]}") + if not pw.client.change_site(configuration[CONFIG_TYPE.PW_SITEID]): + log.error("Fatal Error: Unable to connect. Please fix config and restart.") + while True: + try: + time.sleep(5) # Infinite loop to keep container running + except (KeyboardInterrupt, SystemExit): + sys.exit(0) + else: + proxystats[PROXY_STATS_TYPE.MODE] = "Local" + log.info("pyPowerwall Proxy Server - Local Mode") + log.info(f"Connected to Energy Gateway {configuration[CONFIG_TYPE.PW_HOST]} ({site_name.strip()})") + if pw.tedapi: + proxystats[PROXY_STATS_TYPE.TEDAPI] = True + proxystats[PROXY_STATS_TYPE.TEDAPI_MODE] = pw.tedapi_mode + proxystats[PROXY_STATS_TYPE.PW3] = pw.tedapi.pw3 + log.info(f"TEDAPI Mode Enabled for Device Vitals ({pw.tedapi_mode})") + server.serve_forever() except (Exception, KeyboardInterrupt, SystemExit): log.info(f"Server on {host}:{port} stopped") From b8628fe121b04fe56382f3716776a58710656422 Mon Sep 17 00:00:00 2001 From: Jason Cox Date: Tue, 14 Jan 2025 22:39:09 -0800 Subject: [PATCH 29/35] Remove hardcoded port in URL --- proxy/web/example.html | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/proxy/web/example.html b/proxy/web/example.html index cafcb3a..aab60da 100644 --- a/proxy/web/example.html +++ b/proxy/web/example.html @@ -16,7 +16,9 @@

iFrame Example