# -*- coding: utf-8 -*- """Main module.""" import copy import json import logging import ssl import threading import time from datetime import datetime, timezone import requests import websocket _LOGGER = logging.getLogger(__name__) USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64; rv:142.0) Gecko/20100101 Firefox/142.0" WF_BASE_URL = "https://symphony.mywaterfurnace.com" WF_LOGIN_URL = f"{WF_BASE_URL}/account/login" WF_WS_URL = "wss://awlclientproxy.mywaterfurnace.com/" GS_BASE_URL = "https://symphony.mygeostar.com" GS_LOGIN_URL = f"{GS_BASE_URL}/account/login" GS_WS_URL = "wss://awlclientproxy.mygeostar.com/" FURNACE_MODE = ( "Standby", "Fan Only", "Cooling 1", "Cooling 2", "Reheat", "Heating 1", "Heating 2", "E-Heat", "Aux Heat", "Lockout", ) ACTIVE_MODE = ( "Off", "Auto", "Cool", "Heat", "E-Heat", ) FAN_MODE = ( "Auto", "Continuous", "Intermittent", ) FAILED_LOGIN = ( "Your login failed. Please check your email address / password and try again." ) TIMEOUT = 30 ERROR_INTERVAL = 300 DATA_REQUEST = { "cmd": "read", "tid": None, "awlid": None, "zone": 0, "rlist": [ # the list of sensors to return readings for "compressorpower", "fanpower", "auxpower", "looppumppower", "totalunitpower", "AWLABCType", "ModeOfOperation", "ActualCompressorSpeed", "AirflowCurrentSpeed", "AuroraOutputEH1", "AuroraOutputEH2", "AuroraOutputCC", "AuroraOutputCC2", "TStatDehumidSetpoint", "TStatHumidSetpoint", "TStatRelativeHumidity", "LeavingAirTemp", "TStatRoomTemp", "EnteringWaterTemp", "AOCEnteringWaterTemp", "LeavingWaterTemp", "WaterFlowRate", "lockoutstatus", "lastfault", "lastlockout", "humidity_offset_settings", "humidity", "outdoorair", "homeautomationalarm1", "homeautomationalarm2", "roomtemp", "activesettings", "TStatActiveSetpoint", "TStatMode", "TStatHeatingSetpoint", "TStatCoolingSetpoint", "AWLTStatType", ], "source": "consumer dashboard", } class WFException(Exception): pass class WFCredentialError(WFException): pass class WFWebsocketClosedError(WFException): pass class WFError(WFException): pass class WFNoDataError(WFException): pass class SymphonyGeothermal(object): def __init__( self, base_url, login_url, ws_url, user, passwd, max_fails=5, device=0, location=0, sessionid=None, ): self.base_url = base_url self.login_url = login_url self.ws_url = ws_url self.user = user self.passwd = passwd self.location = location self.device = device self.gwid = None self.sessionid = sessionid self.tid = 0 # For retry logic self.max_fails = max_fails self.fails = 0 self._location_data = None # Unique ID for the account, regardless of email changes. self.account_id = None _LOGGER.debug(self) def __repr__(self): return f"" def next_tid(self): self.tid = (self.tid + 1) % 100 def _check_session_id(self): """Check an existing session ID.""" _LOGGER.debug("Checking existing session.") headers = { "user-agent": USER_AGENT, } res = requests.get( f"{self.base_url}/api.php/user", headers=headers, cookies={ "legal-acknowledge": "yes", "sessionid": self.sessionid, }, timeout=TIMEOUT, allow_redirects=False, ) try: res.json()["emailaddress"] except KeyError: _LOGGER.error( "Existing session is not valid" " A lot of debug info coming..." ) _LOGGER.debug("Response: %s", res) _LOGGER.debug("Response Cookies: %s", res.cookies) _LOGGER.debug("Response Content: %s", res.content) raise WFCredentialError() def _get_session_id(self): data = dict( emailaddress=self.user, password=self.passwd, op="login", redirect="/" ) headers = { "user-agent": USER_AGENT, } res = requests.post( self.login_url, data=data, headers=headers, cookies={ "legal-acknowledge": "yes", "energy-base-price": "0.15", "temp_unit": "f", }, timeout=TIMEOUT, allow_redirects=False, ) try: self.sessionid = res.cookies["sessionid"] except KeyError: _LOGGER.error( "Did not find expected session cookie, login failed." " A lot of debug info coming..." ) _LOGGER.debug("Response: %s", res) _LOGGER.debug("Response Cookies: %s", res.cookies) _LOGGER.debug("Response Content: %s", res.content) if FAILED_LOGIN in res.content: _LOGGER.error( "Failed to log in, are you sure your user / password are correct" ) raise WFCredentialError() else: raise WFError() def _login_ws(self): # The following is needed to allow legacy negotiation because # WF is kind of slow in updating infrastructure sslopt = {} ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) ctx.options |= 0x4 # OP_LEGACY_SERVER_CONNECT sslopt.update({"context": ctx}) self.ws = websocket.create_connection( self.ws_url, timeout=TIMEOUT, sslopt=sslopt ) login = { "cmd": "login", "tid": self.tid, "source": "consumer dashboard", "sessionid": self.sessionid, } self.ws.send(json.dumps(login)) # TODO(sdague): we should probably check the response, but # it's not clear anything is useful in it. recv = self.ws.recv() data = json.loads(recv) _LOGGER.debug("Login response: %s", data) if "key" in data: self.account_id = data["key"] locations = data["locations"] self._location_data = locations location = None if isinstance(self.location, int): try: location = locations[self.location] except Exception: raise WFError( "Location index out of range. Max index is %s", len(locations) - 1 ) elif isinstance(self.location, str): for index, location_data in enumerate(locations): location_description = location_data.get("description") if location_description == self.location: location = locations[index] break if not location: raise WFError("Unable to find location: %s", self.location) else: raise WFError( "Unknown location type (%s): %s. Should be int or str", type(self.location), self.location, ) gateways = location["gateways"] device = None if isinstance(self.device, int): try: device = gateways[self.device] except Exception: raise WFError( "Device index out of range. Max index is %s", len(gateways) - 1 ) elif isinstance(self.device, str): for index, gateway_data in enumerate(gateways): gateway_gwid = gateway_data.get("gwid") gateway_description = gateway_data.get("description") if gateway_gwid == self.device or gateway_description == self.device: device = gateways[index] break if not device: raise WFError("Unable to find device: %s", self.device) else: raise WFError( "Unknown device type (%s): %s. Should be int or str", type(self.device), self.device, ) self.gwid = device["gwid"] self.next_tid() def login(self): if self.sessionid: try: self._check_session_id() except WFCredentialError: self._get_session_id() else: self._get_session_id() # reset the transaction id if we start over self.tid = 1 self._login_ws() @property def locations(self): """Get all available locations""" if not isinstance(self._location_data, list): return None return [WFLocation(loc) for loc in self._location_data] @property def devices(self): """Get all devices for the current location.""" if self.locations is None: return None target_location = None if isinstance(self.location, int): try: target_location = self.locations[self.location] except IndexError: raise WFError( f"Location index out of range. Max index is {len(self.locations) - 1}" ) else: raise WFError("Unknown location type") return target_location.gateways def _abort(self, *args, **kwargs): _LOGGER.warning("Timeout on websocket request. Aborting websocket") try: self.ws.abort() except Exception: _LOGGER.exception("Can't abort, this might be interesting....") def _ws_read(self): req = copy.deepcopy(DATA_REQUEST) req["tid"] = self.tid req["awlid"] = self.gwid _LOGGER.debug("Req: %s", req) timer = threading.Timer(10.0, self._abort, [self]) timer.start() self.ws.send(json.dumps(req)) _LOGGER.debug("Successful send") data = self.ws.recv() _LOGGER.debug("Successful recv") timer.cancel() return data def _ws_write(self, **kwargs): req = { "cmd": "write", "tid": self.tid, "awlid": self.gwid, "source": "tstat", } req.update(kwargs) _LOGGER.debug("Write req: %s", req) timer = threading.Timer(10.0, self._abort, [self]) timer.start() try: self.ws.send(json.dumps(req)) data = self.ws.recv() timer.cancel() self.next_tid() datadecoded = json.loads(data) _LOGGER.debug("Write resp: %s", datadecoded) if datadecoded["err"]: raise WFError(datadecoded["err"]) return datadecoded except WFError: raise except websocket.WebSocketConnectionClosedException: _LOGGER.exception("Websocket closed, probably from a timeout") raise WFWebsocketClosedError() except ValueError: _LOGGER.exception("Unable to decode data as json: %s", data) raise WFWebsocketClosedError() except Exception: _LOGGER.exception("Unknown exception, socket probably failed") raise WFWebsocketClosedError() finally: timer.cancel() def read(self): try: data = self._ws_read() self.next_tid() datadecoded = json.loads(data) _LOGGER.debug("Resp: %s", datadecoded) if not datadecoded["err"]: return WFReading(datadecoded) else: raise WFError(datadecoded["err"]) except websocket.WebSocketConnectionClosedException: _LOGGER.exception("Websocket closed, probably from a timeout") raise WFWebsocketClosedError() except ValueError: _LOGGER.exception("Unable to decode data as json: %s", data) raise WFWebsocketClosedError() except Exception: _LOGGER.exception("Unknown exception, socket probably failed") raise WFWebsocketClosedError() def read_with_retry(self): while self.fails <= self.max_fails: try: if self.fails >= 1: self.login() _LOGGER.debug("Reconnected to furnace") data = self.read() self.fails = 0 return data except requests.exceptions.RequestException: self.fails = self.fails + 1 _LOGGER.exception("relogin failed, trying again") time.sleep(self.fails * ERROR_INTERVAL) except WFWebsocketClosedError: self.fails = self.fails + 1 _LOGGER.exception("websocket read failed, reconnecting") time.sleep(self.fails * ERROR_INTERVAL) raise WFWebsocketClosedError("Failed to refresh credentials after retries") def set_mode(self, mode): """Set the active thermostat mode. Args: mode: Integer 0-4 (Off=0, Auto=1, Cool=2, Heat=3, E-Heat=4) """ if isinstance(mode, bool) or not isinstance(mode, int) or mode < 0 or mode > 4: raise ValueError(f"mode must be an integer 0-4, got: {mode}") return self._ws_write(activemode_write=mode) def set_cooling_setpoint(self, temperature): """Set the cooling temperature setpoint. Only effective when in Cool or Auto mode. Args: temperature: Temperature in degrees Fahrenheit (60-90) """ if not isinstance(temperature, (int, float)): raise ValueError( f"temperature must be numeric, got: {type(temperature).__name__}" ) if temperature < 60 or temperature > 90: raise ValueError( f"cooling temperature must be between 60-90F, got: {temperature}" ) return self._ws_write(coolingsp_write=temperature) def set_heating_setpoint(self, temperature): """Set the heating temperature setpoint. Only effective when in Heat, Auto, or E-Heat mode. Args: temperature: Temperature in degrees Fahrenheit (40-80) """ if not isinstance(temperature, (int, float)): raise ValueError( f"temperature must be numeric, got: {type(temperature).__name__}" ) if temperature < 40 or temperature > 80: raise ValueError( f"heating temperature must be between 40-80F, got: {temperature}" ) return self._ws_write(heatingsp_write=temperature) def set_fan_mode(self, mode, intertimeon=None, intertimeoff=None): """Set the fan mode. Args: mode: Integer 0-2 (Auto=0, Continuous=1, Intermittent=2) intertimeon: Minutes on-time, required when mode=2 intertimeoff: Minutes off-time, required when mode=2 """ if isinstance(mode, bool) or not isinstance(mode, int) or mode < 0 or mode > 2: raise ValueError(f"fan mode must be an integer 0-2, got: {mode}") if mode == 2: if intertimeon is None or intertimeoff is None: raise ValueError( "intertimeon and intertimeoff are required for intermittent mode" ) if not isinstance(intertimeon, int) or intertimeon <= 0: raise ValueError( f"intertimeon must be a positive integer, got: {intertimeon}" ) if not isinstance(intertimeoff, int) or intertimeoff <= 0: raise ValueError( f"intertimeoff must be a positive integer, got: {intertimeoff}" ) return self._ws_write( fanmode_write=mode, intertimeon_write=intertimeon, intertimeoff_write=intertimeoff, ) else: if intertimeon is not None or intertimeoff is not None: raise ValueError( "intertimeon and intertimeoff are only valid for intermittent mode" ) return self._ws_write(fanmode_write=mode) def set_humidity(self, humidity): """Set the humidification target. Reads current sensor values first to preserve existing humidity_offset_settings and dehumidification setpoint. Args: humidity: Target humidity percentage (15-95) """ if ( isinstance(humidity, bool) or not isinstance(humidity, int) or humidity < 15 or humidity > 95 ): raise ValueError( f"humidity must be an integer between 15-95, got: {humidity}" ) reading = self.read() return self._ws_write( humidity_offset_settings=reading.raw_humidity_offset_settings, dehumid_humid_sp={ "dehumidification": reading.tstatdehumidsetpoint, "humidification": humidity, }, ) def get_energy_data( self, start_date, end_date, frequency="1H", timezone_str="America/New_York" ): """Get energy data for a date range. Args: start_date: Start date as string in YYYY-MM-DD format end_date: End date as string in YYYY-MM-DD format frequency: Data frequency - "1D" (daily), "1H" (hourly), or "15min" (15 minutes) timezone_str: Timezone string (e.g., "America/New_York") Returns: WFEnergyData object containing energy readings Raises: WFCredentialError: If not logged in or session invalid WFError: If API request fails """ if not self.sessionid or not self.gwid: raise WFCredentialError("Must login before getting energy data") # Validate frequency valid_frequencies = ["1D", "1H", "15min"] if frequency not in valid_frequencies: raise ValueError(f"Invalid frequency. Must be one of {valid_frequencies}") # Build the API URL url = ( f"{self.base_url}/api.php/v2/gateway/{self.gwid}/energy" f"?freq={frequency}&start={start_date}" f"&timezone={timezone_str}&end={end_date}" ) headers = { "user-agent": USER_AGENT, } cookies = { "sessionid": self.sessionid, "legal-acknowledge": "yes", } _LOGGER.debug(f"Requesting energy data from: {url}") try: res = requests.get( url, headers=headers, cookies=cookies, timeout=TIMEOUT, ) res.raise_for_status() if not res.text.strip(): raise WFNoDataError( f"No energy data available for {start_date} to {end_date}" ) data = res.json() _LOGGER.debug(f"Received energy data: {len(data.get('index', []))} records") return WFEnergyData(data) except WFNoDataError: raise except requests.exceptions.HTTPError as e: _LOGGER.error(f"HTTP error getting energy data: {e}") raise WFError(f"Failed to get energy data: {e}") except requests.exceptions.RequestException as e: _LOGGER.error(f"Request error getting energy data: {e}") raise WFError(f"Failed to get energy data: {e}") except (ValueError, KeyError) as e: _LOGGER.error(f"Error parsing energy data response: {e}") raise WFError(f"Invalid energy data response: {e}") class WaterFurnace(SymphonyGeothermal): def __init__(self, user, passwd, max_fails=5, device=0, location=0, sessionid=None): super().__init__( WF_BASE_URL, WF_LOGIN_URL, WF_WS_URL, user, passwd, max_fails, device, location, sessionid=sessionid, ) class GeoStar(SymphonyGeothermal): def __init__(self, user, passwd, max_fails=5, device=0, location=0, sessionid=None): super().__init__( GS_BASE_URL, GS_LOGIN_URL, GS_WS_URL, user, passwd, max_fails, device, location, sessionid=sessionid, ) class ActiveSettings: def __init__(self, data=None): if data is None: data = {} self.activemode = data.get("activemode") self.heatingsp_read = data.get("heatingsp_read") self.coolingsp_read = data.get("coolingsp_read") self.fanmode_read = data.get("fanmode_read") self.temporaryoverride = data.get("temporaryoverride") self.permanenthold = data.get("permanenthold") self.vacationhold = data.get("vacationhold") self.onpeakhold = data.get("onpeakhold") self.superboost = data.get("superboost") self.tstatmode = data.get("tstatmode") self.intertimeon_read = data.get("intertimeon_read") self.intertimeoff_read = data.get("intertimeoff_read") @property def mode(self): if self.activemode is not None: return ACTIVE_MODE[self.activemode] return None def __repr__(self): return f"" class WFReading(object): def __init__(self, data=None): if data is None: data = {} self.zone = data.get("zone", 0) self.err = data.get("err", "") self.awlid = data.get("awlid", "") self.tid = data.get("tid", 0) # power (Watts) self.compressorpower = data.get("compressorpower") self.fanpower = data.get("fanpower") self.auxpower = data.get("auxpower") self.looppumppower = data.get("looppumppower") self.totalunitpower = data.get("totalunitpower") # modes (0 - 10) self.modeofoperation = data.get("modeofoperation") # fan speed (0 - 10) self.airflowcurrentspeed = data.get("airflowcurrentspeed") # compressor speed self.actualcompressorspeed = data.get("actualcompressorspeed") # humidity (%) self.tstatdehumidsetpoint = data.get("tstatdehumidsetpoint") self.tstathumidsetpoint = data.get("tstathumidsetpoint") self.tstatrelativehumidity = data.get("tstatrelativehumidity") # temps (degrees F) self.leavingairtemp = data.get("leavingairtemp") self.tstatroomtemp = data.get("tstatroomtemp") self.enteringwatertemp = data.get("enteringwatertemp") self.leavingwatertemp = data.get("leavingwatertemp") # setpoints (degrees F) self.tstatheatingsetpoint = data.get("tstatheatingsetpoint") self.tstatcoolingsetpoint = data.get("tstatcoolingsetpoint") self.tstatactivesetpoint = data.get("tstatactivesetpoint") # Loop water flow rate (gallons per minute) self.waterflowrate = data.get("waterflowrate") # raw humidity settings for write passthrough self.raw_humidity_offset_settings = data.get("humidity_offset_settings", {}) # active settings self.activesettings = ActiveSettings(data.get("activesettings")) @property def mode(self): return FURNACE_MODE[self.modeofoperation] def __repr__(self): return ( "" % ( self.totalunitpower, self.mode, self.activesettings.mode, self.enteringwatertemp, self.leavingairtemp, self.tstatroomtemp, self.tstatactivesetpoint, ) ) class WFEnergyReading(object): """Represents a single energy data reading for a specific time period.""" def __init__(self, timestamp_ms, values, columns): """Initialize energy reading. Args: timestamp_ms: Unix timestamp in milliseconds values: List of values corresponding to columns columns: List of column names """ self.timestamp_ms = timestamp_ms # Convert milliseconds to seconds for datetime self.timestamp = datetime.fromtimestamp(timestamp_ms / 1000.0, tz=timezone.utc) # Create a mapping for easy access data_dict = {} for i, col in enumerate(columns): if i < len(values): data_dict[col] = values[i] # Common fields for all frequencies self.total_heat_1 = data_dict.get("total_heat_1") self.total_heat_2 = data_dict.get("total_heat_2") self.total_cool_1 = data_dict.get("total_cool_1") self.total_cool_2 = data_dict.get("total_cool_2") self.total_electric_heat = data_dict.get("total_electric_heat") self.total_fan_only = data_dict.get("total_fan_only") self.total_loop_pump = data_dict.get("total_loop_pump") self.total_dehumidification = data_dict.get("total_dehumidification") self.total_power = data_dict.get("total_power") self.total_records = data_dict.get("total_records") # Runtime fields (hour/15min frequency) self.runtime_heat_1 = data_dict.get("runtime_heat_1") self.runtime_heat_2 = data_dict.get("runtime_heat_2") self.runtime_cool_1 = data_dict.get("runtime_cool_1") self.runtime_cool_2 = data_dict.get("runtime_cool_2") self.runtime_electric_heat = data_dict.get("runtime_electric_heat") self.runtime_fan_only = data_dict.get("runtime_fan_only") self.runtime_dehumidification = data_dict.get("runtime_dehumidification") self.cool_runtime = data_dict.get("cool_runtime") self.heat_runtime = data_dict.get("heat_runtime") # Daily frequency specific fields self.id = data_dict.get("id") self.defrost_runtime = data_dict.get("defrost_runtime") self.dehumidification_runtime = data_dict.get("dehumidification_runtime") self.time_zone = data_dict.get("time_zone") # Store all raw data for any custom access self._raw_data = data_dict def get(self, key, default=None): """Get any field by column name. Args: key: Column name default: Default value if key not found Returns: Value for the given column or default """ return self._raw_data.get(key, default) def __repr__(self): return f"" class WFEnergyData(object): """Container for energy data with multiple readings.""" def __init__(self, data=None): """Initialize energy data from API response. Args: data: Dictionary containing columns, index, and data arrays """ if data is None: data = {} self.columns = data.get("columns", []) self.index = data.get("index", []) self.data = data.get("data", []) # Create reading objects for easier access self.readings = [] for i, timestamp in enumerate(self.index): if i < len(self.data): reading = WFEnergyReading(timestamp, self.data[i], self.columns) self.readings.append(reading) def __iter__(self): """Allow iteration over readings.""" return iter(self.readings) def __len__(self): """Return number of readings.""" return len(self.readings) def __getitem__(self, index): """Allow indexed access to readings.""" return self.readings[index] def __repr__(self): return ( f"" ) class WFGateway: """Represents a Symphony gateway/device.""" def __init__(self, data): if "gwid" not in data: raise ValueError("Gateway data must contain 'gwid' field") self.gwid = data["gwid"] self.description = data.get("description", self.gwid) self.type = data.get("type") self.awltstattype = data.get("awltstattype") self.awltstattypedesc = data.get("awltstattypedesc") self.iz2_max_zones = data.get("iz2_max_zones") self.awlabctypedesc = data.get("awlabctypedesc") self.awlabctype = data.get("awlabctype") self.blowertype = data.get("blowertype") self.online = data.get("online", 1) # Assume online if not specified self.tstat_name = data.get("tstat_name") # Store raw data for debugging/future extensibility self._raw = data def is_online(self): """Check if gateway is currently online.""" return bool(self.online) def __repr__(self): return f"" class WFLocation: """Represents a Symphony location.""" def __init__(self, data): self.description = data.get("description", "Unknown") self.postal = data.get("postal") self.city = data.get("city") self.state = data.get("state") self.country = data.get("country") self.latitude = data.get("latitude") self.longitude = data.get("longitude") # Convert gateways to WFGateway objects self.gateways = [WFGateway(gw) for gw in data.get("gateways", [])] # Store raw data for debugging/future extensibility self._raw = data def __repr__(self): return ( f"" )