def __init__( self, websession, username, password, device_id, pin, device_name, country=sc.COUNTRY_USA, update_interval=sc.DEFAULT_UPDATE_INTERVAL, fetch_interval=sc.DEFAULT_FETCH_INTERVAL, ): """Initialize controller. Args: websession (aiohttp.ClientSession): An instance of aiohttp.ClientSession. username (str): Username used for the MySubaru mobile app. password (str): Password used for the MySubaru mobile app. device_id (str): Alphanumeric designator that Subaru API uses to track individual device authorization. pin (str): 4 digit pin number required to send remote vehicle commands. device_name (str): Human friendly name that is associated with `device_id` (shows on mysubaru.com profile "devices"). country (str): Country for MySubaru Account [CAN, USA]. update_interval (int, optional): Seconds between requests for vehicle send update fetch_interval (int, optional): Seconds between fetches of Subaru's cached vehicle information """ self._connection = Connection(websession, username, password, device_id, device_name, country) self._country = country self._update_interval = update_interval self._fetch_interval = fetch_interval self._vehicles = {} self._pin = pin self._controller_lock = asyncio.Lock() self._pin_lockout = False self.version = subarulink.__version__
def __init__( self, websession, username, password, device_id, pin, device_name, update_interval=7200, fetch_interval=300, ): """Initialize controller. Args: websession (aiohttp.ClientSession): Session username (Text): Username password (Text): Password device_id (Text): Alphanumeric designator that Subaru API uses to determine if a device is authorized to send remote requests pin (Text): 4 digit pin number string required to submit Subaru Remote requests device_name (Text): Human friendly name that is associated with device_id (shows on mysubaru.com profile "devices") update_interval (int, optional): Seconds between requests for vehicle send update fetch_interval (int, optional): Seconds between fetches of Subaru's cached vehicle information """ self._connection = Connection(websession, username, password, device_id, device_name) self._update_interval = update_interval self._fetch_interval = fetch_interval self._car_data = {} self._update = {} self._pin = pin self._vin_id_map = {} self._vin_name_map = {} self._api_gen = {} self._lock = {} self._hasEV = {} self._hasRemote = {} self._hasRES = {} self._controller_lock = asyncio.Lock() self._last_update_time = {} self._last_fetch_time = {} self._cars = []
class Controller: """Controller to interact with Subaru Starlink mobile app API.""" def __init__( self, websession, username, password, device_id, pin, device_name, country=sc.COUNTRY_USA, update_interval=sc.DEFAULT_UPDATE_INTERVAL, fetch_interval=sc.DEFAULT_FETCH_INTERVAL, ): """Initialize controller. Args: websession (aiohttp.ClientSession): An instance of aiohttp.ClientSession. username (str): Username used for the MySubaru mobile app. password (str): Password used for the MySubaru mobile app. device_id (str): Alphanumeric designator that Subaru API uses to track individual device authorization. pin (str): 4 digit pin number required to send remote vehicle commands. device_name (str): Human friendly name that is associated with `device_id` (shows on mysubaru.com profile "devices"). country (str): Country for MySubaru Account [CAN, USA]. update_interval (int, optional): Seconds between requests for vehicle send update fetch_interval (int, optional): Seconds between fetches of Subaru's cached vehicle information """ self._connection = Connection(websession, username, password, device_id, device_name, country) self._country = country self._update_interval = update_interval self._fetch_interval = fetch_interval self._vehicles = {} self._pin = pin self._controller_lock = asyncio.Lock() self._pin_lockout = False self.version = subarulink.__version__ async def connect(self, test_login=False): """ Connect to Subaru Remote Services API. Args: test_login (bool, optional): If `True` then username/password is verified only. Returns: bool: `True` if success, `False` if failure Raises: InvalidCredentials: If login credentials are incorrect. IncompleteCredentials: If login credentials were not provided. SubaruException: If authorization and registration sequence fails for any other reason. """ _LOGGER.debug("subarulink %s", self.version) _LOGGER.debug("Connecting controller to Subaru Remote Services") vehicle_list = await self._connection.connect(test_login=test_login) if not test_login: for vehicle in vehicle_list: self._parse_vehicle(vehicle) _LOGGER.debug("Subaru Remote Services Ready") return True def is_pin_required(self): """ Return if a vehicle with an active remote service subscription exists. Returns: bool: `True` if PIN is required. `False` if PIN not required. """ for vin in self._vehicles: if self.get_remote_status(vin): return True return False async def test_pin(self): """ Test if stored PIN is valid for Remote Services. Returns: bool: `True` if PIN is correct. `False` if no vehicle with remote capability exists in account. Raises: InvalidPIN: If PIN is incorrect. SubaruException: If other failure occurs. """ _LOGGER.info("Testing PIN for validity with Subaru remote services") for vin, car_data in self._vehicles.items(): if self.get_remote_status(vin): await self._connection.validate_session(vin) api_gen = self.get_api_gen(vin) form_data = {"pin": self._pin, "vin": vin, "delay": 0} test_path = sc.API_G1_LOCATE_UPDATE if api_gen == sc.FEATURE_G1_TELEMATICS else sc.API_G2_LOCATE_UPDATE async with car_data[sc.VEHICLE_LOCK]: js_resp = await self._post(test_path, json_data=form_data) _LOGGER.debug(pprint.pformat(js_resp)) if js_resp["success"]: _LOGGER.info("PIN is valid for Subaru remote services") return True _LOGGER.info( "No active vehicles with remote services subscription - PIN not required" ) return False def get_vehicles(self): """ Return list of VINs available to user on Subaru Remote Services API. Returns: List: A list containing the VINs of all vehicles registered to the Subaru account. """ return list(self._vehicles.keys()) def get_ev_status(self, vin): """ Get whether the specified VIN is an Electric Vehicle. Args: vin (str): The VIN to check. Returns: bool: `True` if `vin` is an Electric Vehicle, `False` if not. None: If `vin` is invalid. """ vehicle = self._vehicles.get(vin.upper()) status = None if vehicle: status = sc.FEATURE_PHEV in vehicle[sc.VEHICLE_FEATURES] _LOGGER.debug("Getting EV Status %s:%s", vin, status) return status def get_remote_status(self, vin): """ Get whether the specified VIN has remote locks/horn/light service available. Args: vin (str): The VIN to check. Returns: bool: `True` if `vin` has remote capability and an active service plan, `False` if not. None: If `vin` is invalid. """ vehicle = self._vehicles.get(vin.upper()) status = None if vehicle: status = sc.FEATURE_REMOTE in vehicle[ sc. VEHICLE_SUBSCRIPTION_FEATURES] and self.get_subscription_status( vin) _LOGGER.debug("Getting remote Status %s:%s", vin, status) return status def get_res_status(self, vin): """ Get whether the specified VIN has remote engine start service available. Args: vin (str): The VIN to check. Returns: bool: `True` if `vin` has remote engine (or EV) start capability and an active service plan, `False` if not. None: If `vin` is invalid. """ vehicle = self._vehicles.get(vin.upper()) status = None if vehicle: status = sc.FEATURE_REMOTE_START in vehicle[ sc.VEHICLE_FEATURES] and self.get_remote_status(vin) _LOGGER.debug("Getting RES Status %s:%s", vin, status) return status def get_safety_status(self, vin): """ Get whether the specified VIN is has an active Starlink Safety Plus service plan. Args: vin (str): The VIN to check. Returns: bool: `True` if `vin` has an active Safety Plus service plan, `False` if not. None: If `vin` is invalid. """ vehicle = self._vehicles.get(vin.upper()) status = None if vehicle: status = sc.FEATURE_SAFETY in vehicle[ sc. VEHICLE_SUBSCRIPTION_FEATURES] and self.get_subscription_status( vin) _LOGGER.debug("Getting Safety Plus Status %s:%s", vin, status) return status def get_subscription_status(self, vin): """ Get whether the specified VIN has an active service plan. Args: vin (str): The VIN to check. Returns: bool: `True` if `vin` has an active service plan, `False` if not. None: If `vin` is invalid. """ vehicle = self._vehicles.get(vin.upper()) status = None if vehicle: status = vehicle[ sc.VEHICLE_SUBSCRIPTION_STATUS] == sc.FEATURE_ACTIVE _LOGGER.debug("Getting subscription Status %s:%s", vin, status) return status def get_api_gen(self, vin): """ Get the Subaru telematics API generation of a specified VIN. Args: vin (str): The VIN to check. Returns: str: `subarulink.const.FEATURE_G1_TELEMATICS` or `subarulink.const.FEATURE_G2_TELEMATICS` None: If `vin` is invalid. """ vehicle = self._vehicles.get(vin.upper()) result = None if vehicle: if sc.FEATURE_G1_TELEMATICS in vehicle[sc.VEHICLE_FEATURES]: result = sc.FEATURE_G1_TELEMATICS if sc.FEATURE_G2_TELEMATICS in vehicle[sc.VEHICLE_FEATURES]: result = sc.FEATURE_G2_TELEMATICS _LOGGER.debug("Getting vehicle API gen %s:%s", vin, result) return result def vin_to_name(self, vin): """ Get the nickname of a specified VIN. Args: vin (str): The VIN to check. Returns: str: Display name associated with `vin` None: If `vin` is invalid. """ vehicle = self._vehicles.get(vin.upper()) result = None if vehicle: result = vehicle[sc.VEHICLE_NAME] return result async def get_data(self, vin): """ Get locally cached vehicle data. Fetch from Subaru API if not present. Args: vin (str): The VIN to get. Returns: dict: Vehicle information. None: If `vin` is invalid. Raises: SubaruException: If fetch operation fails. """ vehicle = self._vehicles.get(vin.upper()) result = None if vehicle: if len(vehicle.get(sc.VEHICLE_STATUS)) == 0: await self.fetch(vin) result = self._vehicles[vin.upper()] return result async def list_climate_preset_names(self, vin): """ Get list of climate control presets. Args: vin (str): The VIN of the vehicle. Returns: list: containing climate preset names. None: If `preset_name` not found. Raises: VehicleNotSupported: if vehicle/subscription not supported """ self._validate_remote_capability(vin) if not self._vehicles[vin].get("climate"): await self._fetch_climate_presets(vin) return [i[sc.PRESET_NAME] for i in self._vehicles[vin]["climate"]] async def get_climate_preset_by_name(self, vin, preset_name): """ Get climate control preset by name. Args: vin (str): The VIN of the vehicle. preset_name (str): Name of climate settings preset. Returns: dict: containing climate preset parameters. None: If `preset_name` not found. Raises: VehicleNotSupported: if vehicle/subscription not supported """ self._validate_remote_capability(vin) if not self._vehicles[vin].get("climate"): await self._fetch_climate_presets(vin) for preset in self._vehicles[vin]["climate"]: if preset["name"] == preset_name: return preset async def get_user_climate_preset_data(self, vin): """ Get user climate control preset data. Args: vin (str): The VIN of the vehicle. Returns: list: containing up to 4 climate preset data dicts. None: If `preset_name` not found. Raises: VehicleNotSupported: if vehicle/subscription not supported """ self._validate_remote_capability(vin) if not self._vehicles[vin].get("climate"): await self._fetch_climate_presets(vin) return [ i for i in self._vehicles[vin]["climate"] if i[sc.PRESET_TYPE] == sc.PRESET_TYPE_USER ] async def delete_climate_preset_by_name(self, vin, preset_name): """ Delete climate control user preset by name. Args: vin (str): The VIN of the vehicle. preset_name (str): Name of climate settings preset. Returns: bool: `True` if successful. Raises: SubaruException: if `preset_name` not found VehicleNotSupported: if vehicle/subscription not supported """ self._validate_remote_capability(vin) preset = await self.get_climate_preset_by_name(vin, preset_name) if preset and preset["presetType"] == "userPreset": user_presets = [ i for i in self._vehicles[vin]["climate"] if i["presetType"] == "userPreset" ] user_presets.remove(preset) return await self.update_user_climate_presets(vin, user_presets) raise SubaruException(f"User preset name '{preset_name}' not found") async def update_user_climate_presets(self, vin, preset_data): """ Save user defined climate control settings to Subaru. Args: vin (str): The VIN to save climate settings to. preset_data (list): List of Climate settings dicts to save. Returns: bool: `True` upon success. None: If `vin` is invalid or unsupported. Raises: SubaruException: If preset_data is invalid or fails to save. VehicleNotSupported: if vehicle/subscription not supported """ self._validate_remote_capability(vin) if not self._vehicles[vin].get("climate"): await self._fetch_climate_presets(vin) if not isinstance(preset_data, list) and not isinstance( preset_data[0], dict): raise SubaruException( "Preset data must be a list of climate settings dicts") if len(preset_data) > 4: raise SubaruException( "Preset list may have a maximum of 4 entries") for preset in preset_data: self._validate_remote_start_params(vin, preset) await self._connection.validate_session(vin) js_resp = await self._post(sc.API_G2_SAVE_RES_SETTINGS, json_data=preset_data) _LOGGER.debug(js_resp) success = js_resp["success"] await self._fetch_climate_presets(vin) return success async def fetch(self, vin, force=False): """ Fetch latest vehicle status data cached on Subaru servers. Args: vin (str): The VIN to fetch. force (bool, optional): Override `fetch_interval` value and force a query. Returns: bool: `True` upon success. Status is not returned by this function. Use `get_data()` to retrieve. None: If `vin` is invalid, unsupported, or `fetch_interval` not met. Raises: SubaruException: If failure prevents a valid response from being received. """ vin = vin.upper() async with self._controller_lock: last_fetch = self.get_last_fetch_time(vin) cur_time = time.time() if force or cur_time - last_fetch > self._fetch_interval: result = await self._fetch_status(vin) self._vehicles[vin][sc.VEHICLE_LAST_FETCH] = cur_time return result async def update(self, vin, force=False): """ Initiate remote service command to update vehicle status. Args: vin (str): The VIN to update. force (bool, optional): Override `update_interval` value and force a query. Returns: bool: `True` upon success. Status is not returned by this function. Use `fetch()` then `get_data()` to retrieve. None: If `vin` is invalid, unsupported, or `update_interval` not met. Raises: SubaruException: If failure prevents a valid response from being received. VehicleNotSupported: if vehicle/subscription not supported """ vin = vin.upper() if self.get_remote_status(vin): async with self._controller_lock: last_update = self.get_last_update_time(vin) cur_time = time.time() if force or cur_time - last_update > self._update_interval: result = await self._locate(vin, hard_poll=True) self._vehicles[vin][sc.VEHICLE_LAST_UPDATE] = cur_time return result else: raise VehicleNotSupported( "Active STARLINK Security Plus subscription required.") def get_update_interval(self): """Get current update interval.""" return self._update_interval def set_update_interval(self, value): """ Set new update interval. Args: value (int): New update interval in seconds. Returns: bool: `True` if update succeeded, `False` if update failed. """ old_interval = self._update_interval if value >= 300: self._update_interval = value _LOGGER.debug("Update interval changed from %s to %s", old_interval, value) return True _LOGGER.error("Invalid update interval %s. Keeping old value: %s", value, old_interval) return False def get_fetch_interval(self): """Get current fetch interval.""" return self._fetch_interval def set_fetch_interval(self, value): """ Set new fetch interval. Args: value (int): New fetch interval in seconds. Returns: bool: `True` if update succeeded, `False` if update failed. """ old_interval = self._fetch_interval if value >= 60: self._fetch_interval = value _LOGGER.debug("Fetch interval changed from %s to %s", old_interval, value) return True _LOGGER.error("Invalid fetch interval %s. Keeping old value: %s", value, old_interval) return False def get_last_fetch_time(self, vin): """ Get last time data was fetched for a specific VIN. Args: vin (str): VIN to check. Returns: float: timestamp of last update() None: if `vin` is invalid. """ result = None vehicle = self._vehicles.get(vin.upper()) if vehicle: result = vehicle[sc.VEHICLE_LAST_FETCH] return result def get_last_update_time(self, vin): """ Get last time update remote command was used on a specific VIN. Args: vin (str): VIN to check. Returns: float: timestamp of last update() None: if `vin` is invalid. """ result = None vehicle = self._vehicles.get(vin.upper()) if vehicle: result = vehicle[sc.VEHICLE_LAST_UPDATE] return result async def charge_start(self, vin): """ Send command to start EV charging. Args: vin (str): Destination VIN for command. Returns: bool: `True` upon success. Raises: InvalidPIN: if PIN is incorrect PINLockoutProtect: if PIN was previously incorrect and was not updated RemoteServiceFailure: for failure after request submitted VehicleNotSupported: if vehicle/subscription not supported SubaruException: for other failures """ if self.get_ev_status(vin): success, _ = await self._remote_command(vin.upper(), sc.API_EV_CHARGE_NOW, sc.API_REMOTE_SVC_STATUS) return success raise VehicleNotSupported( "PHEV charging not supported for this vehicle") async def lock(self, vin): """ Send command to lock doors. Args: vin (str): Destination VIN for command. Returns: bool: `True` upon success. Raises: InvalidPIN: if PIN is incorrect PINLockoutProtect: if PIN was previously incorrect and was not updated RemoteServiceFailure: for failure after request submitted VehicleNotSupported: if vehicle/subscription not supported SubaruException: for other failures """ form_data = {"forceKeyInCar": False} success, _ = await self._actuate(vin, sc.API_LOCK, data=form_data) return success async def unlock(self, vin, door=sc.ALL_DOORS): """ Send command to unlock doors. Args: vin (str): Destination VIN for command. door (str, optional): Specify door to unlock. Returns: bool: `True` upon success. Raises: InvalidPIN: if PIN is incorrect PINLockoutProtect: if PIN was previously incorrect and was not updated RemoteServiceFailure: for failure after request submitted VehicleNotSupported: if vehicle/subscription not supported SubaruException: for other failures """ if door in sc.VALID_DOORS: form_data = {sc.WHICH_DOOR: door} success, _ = await self._actuate(vin.upper(), sc.API_UNLOCK, data=form_data) return success raise SubaruException( f"Invalid door '{door}' specified for unlock command") async def lights(self, vin): """ Send command to flash lights. Args: vin (str): Destination VIN for command. Returns: bool: `True` upon success. Raises: InvalidPIN: if PIN is incorrect PINLockoutProtect: if PIN was previously incorrect and was not updated RemoteServiceFailure: for failure after request submitted VehicleNotSupported: if vehicle/subscription not supported SubaruException: for other failures """ poll_url = sc.API_REMOTE_SVC_STATUS if self.get_api_gen(vin) == sc.FEATURE_G1_TELEMATICS: poll_url = sc.API_G1_HORN_LIGHTS_STATUS success, _ = await self._actuate(vin.upper(), sc.API_LIGHTS, poll_url=poll_url) return success async def lights_stop(self, vin): """ Send command to stop flash lights. Args: vin (str): Destination VIN for command. Returns: bool: `True` upon success. Raises: InvalidPIN: if PIN is incorrect PINLockoutProtect: if PIN was previously incorrect and was not updated RemoteServiceFailure: for failure after request submitted VehicleNotSupported: if vehicle/subscription not supported SubaruException: for other failures """ poll_url = sc.API_REMOTE_SVC_STATUS if self.get_api_gen(vin) == sc.FEATURE_G1_TELEMATICS: poll_url = sc.API_G1_HORN_LIGHTS_STATUS success, _ = await self._actuate(vin.upper(), sc.API_LIGHTS_STOP, poll_url=poll_url) return success async def horn(self, vin): """ Send command to sound horn. Args: vin (str): Destination VIN for command. Returns: bool: `True` upon success. Raises: InvalidPIN: if PIN is incorrect PINLockoutProtect: if PIN was previously incorrect and was not updated RemoteServiceFailure: for failure after request submitted VehicleNotSupported: if vehicle/subscription not supported SubaruException: for other failures """ poll_url = sc.API_REMOTE_SVC_STATUS if self.get_api_gen(vin) == sc.FEATURE_G1_TELEMATICS: poll_url = sc.API_G1_HORN_LIGHTS_STATUS success, _ = await self._actuate(vin.upper(), sc.API_HORN_LIGHTS, poll_url=poll_url) return success async def horn_stop(self, vin): """ Send command to sound horn. Args: vin (str): Destination VIN for command. Returns: bool: `True` upon success. Raises: InvalidPIN: if PIN is incorrect PINLockoutProtect: if PIN was previously incorrect and was not updated RemoteServiceFailure: for failure after request submitted VehicleNotSupported: if vehicle/subscription not supported SubaruException: for other failures """ poll_url = sc.API_REMOTE_SVC_STATUS if self.get_api_gen(vin) == sc.FEATURE_G1_TELEMATICS: poll_url = sc.API_G1_HORN_LIGHTS_STATUS success, _ = await self._actuate(vin.upper(), sc.API_HORN_LIGHTS_STOP, poll_url=poll_url) return success async def remote_stop(self, vin): """ Send command to stop engine. Args: vin (str): Destination VIN for command. Returns: bool: `True` upon success. Raises: InvalidPIN: if PIN is incorrect PINLockoutProtect: if PIN was previously incorrect and was not updated RemoteServiceFailure: for failure after request submitted VehicleNotSupported: if vehicle/subscription not supported SubaruException: for other failures """ if self.get_res_status(vin) or self.get_ev_status(vin): success, _ = await self._actuate(vin.upper(), sc.API_G2_REMOTE_ENGINE_STOP) return success raise VehicleNotSupported( "Remote Start not supported for this vehicle") async def remote_start(self, vin, preset_name): """ Send command to start engine and set climate control. Args: vin (str): Destination VIN for command. preset_name (str): Climate control preset name Returns: bool: `True` upon success. Raises: InvalidPIN: if PIN is incorrect PINLockoutProtect: if PIN was previously incorrect and was not updated RemoteServiceFailure: for failure after request submitted VehicleNotSupported: if vehicle/subscription not supported SubaruException: for other failures """ self._validate_remote_capability(vin) preset_data = await self.get_climate_preset_by_name(vin, preset_name) if preset_data: js_resp = await self._post(sc.API_G2_SAVE_RES_QUICK_START_SETTINGS, json_data=preset_data) _LOGGER.debug(pprint.pprint(js_resp)) if js_resp.get("success"): success, _ = await self._actuate(vin, sc.API_G2_REMOTE_ENGINE_START, data=preset_data) return success raise SubaruException( f"Climate preset '{preset_name}' failed: {js_resp}") raise SubaruException(f"Climate preset '{preset_name}' does not exist") def invalid_pin_entered(self): """Return if invalid PIN error was received, thus locking out further remote commands.""" return self._pin_lockout def update_saved_pin(self, new_pin): """ Update the saved PIN used by the controller. Args: new_pin (str): New 4 digit PIN. Returns: bool: `True` if PIN was updated, otherwise `False` """ if new_pin != self._pin: self._pin = new_pin self._pin_lockout = False return True return False async def _get(self, url, params=None): js_resp = await self._connection.get(url, params) self._check_error_code(js_resp) return js_resp async def _post(self, url, params=None, json_data=None): js_resp = await self._connection.post(url, params, json_data) self._check_error_code(js_resp) return js_resp def _check_error_code(self, js_resp): error = js_resp.get("errorCode") if error in [sc.ERROR_SOA_403, sc.ERROR_INVALID_TOKEN]: _LOGGER.debug("SOA 403 error - clearing session cookie") self._connection.reset_session() elif error in [sc.ERROR_INVALID_CREDENTIALS, "SXM40006"]: _LOGGER.error("PIN is not valid for Subaru remote services") self._pin_lockout = True raise InvalidPIN("Invalid PIN! %s" % js_resp) elif error in [ sc.ERROR_SERVICE_ALREADY_STARTED, sc.ERROR_G1_SERVICE_ALREADY_STARTED, ]: pass elif error: _LOGGER.error("Unhandled API error code %s", error) raise SubaruException(f"Unhandled API error: {error} - {js_resp}") def _parse_vehicle(self, vehicle): vin = vehicle["vin"].upper() _LOGGER.debug("Parsing vehicle: %s", vin) self._vehicles[vin] = { sc.VEHICLE_NAME: vehicle[sc.VEHICLE_NAME], sc.VEHICLE_LOCK: asyncio.Lock(), sc.VEHICLE_LAST_FETCH: 0, sc.VEHICLE_LAST_UPDATE: 0, sc.VEHICLE_STATUS: {}, sc.VEHICLE_FEATURES: vehicle[sc.VEHICLE_FEATURES], sc.VEHICLE_SUBSCRIPTION_FEATURES: vehicle[sc.VEHICLE_SUBSCRIPTION_FEATURES], sc.VEHICLE_SUBSCRIPTION_STATUS: vehicle[sc.VEHICLE_SUBSCRIPTION_STATUS], } async def _remote_query(self, vin, cmd): tries_left = 2 js_resp = None while tries_left > 0: await self._connection.validate_session(vin) api_gen = self.get_api_gen(vin) async with self._vehicles[vin][sc.VEHICLE_LOCK]: js_resp = await self._get(cmd.replace("api_gen", api_gen)) _LOGGER.debug(pprint.pformat(js_resp)) if js_resp["success"]: return js_resp if js_resp["errorCode"] == sc.ERROR_SOA_403: tries_left -= 1 else: tries_left = 0 raise SubaruException("Remote query failed. Response: %s " % js_resp) async def _remote_command(self, vin, cmd, poll_url, data=None): try_again = True vin = vin.upper() while try_again: if not self._pin_lockout: # There is some sort of token expiration with the telematics provider that is checked after # a successful remote command is sent causing the status polling to fail and making it seem the # command failed. Workaround is to force a reauth before the command is issued. if self._connection.get_session_age( ) > sc.MAX_SESSION_AGE_MINS: self._connection.reset_session() await self._connection.validate_session(vin) async with self._vehicles[vin][sc.VEHICLE_LOCK]: try_again, success, js_resp = await self._execute_remote_command( vin, cmd, data, poll_url) if success: return success, js_resp else: raise PINLockoutProtect( "Remote command with invalid PIN cancelled to prevent account lockout" ) async def _execute_remote_command(self, vin, cmd, data, poll_url): try_again = False success = None api_gen = self.get_api_gen(vin) form_data = {"pin": self._pin, "delay": 0, "vin": vin} if data: form_data.update(data) js_resp = await self._post(cmd.replace("api_gen", api_gen), json_data=form_data) _LOGGER.debug(pprint.pformat(js_resp)) if js_resp["errorCode"] == sc.ERROR_SOA_403: try_again = True if js_resp["errorCode"] in [ sc.ERROR_G1_SERVICE_ALREADY_STARTED, sc.ERROR_SERVICE_ALREADY_STARTED, ]: await asyncio.sleep(10) try_again = True if js_resp["success"]: req_id = js_resp["data"][sc.SERVICE_REQ_ID] success, js_resp = await self._wait_request_status( vin, req_id, poll_url) return try_again, success, js_resp async def _actuate(self, vin, cmd, data=None, poll_url=sc.API_REMOTE_SVC_STATUS): form_data = {"delay": 0, "vin": vin} if data: form_data.update(data) if self.get_remote_status(vin): return await self._remote_command(vin, cmd, poll_url, data=form_data) raise VehicleNotSupported( "Active STARLINK Security Plus subscription required.") async def _get_vehicle_status(self, vin): await self._connection.validate_session(vin) js_resp = await self._get(sc.API_VEHICLE_STATUS) _LOGGER.debug(pprint.pformat(js_resp)) return js_resp async def _fetch_status(self, vin): _LOGGER.debug("Fetching vehicle status from Subaru") js_resp = await self._get_vehicle_status(vin) if js_resp.get("success") and js_resp.get("data"): data = js_resp["data"] old_status = self._vehicles[vin][sc.VEHICLE_STATUS] status = {} # These values seem to always be valid status[sc.ODOMETER] = data.get(sc.VS_ODOMETER) status[sc.TIMESTAMP] = data.get(sc.VS_TIMESTAMP) / 1000 status[sc.AVG_FUEL_CONSUMPTION] = data.get( sc.VS_AVG_FUEL_CONSUMPTION) status[sc.DIST_TO_EMPTY] = data.get(sc.VS_DIST_TO_EMPTY) status[sc.VEHICLE_STATE] = data.get(sc.VS_VEHICLE_STATE) # Tire pressure is either valid or None. If None and we have a previous value, keep previous, otherwise 0. status[sc.TIRE_PRESSURE_FL] = int( data.get(sc.VS_TIRE_PRESSURE_FL) or (old_status.get(sc.TIRE_PRESSURE_FL) or 0)) status[sc.TIRE_PRESSURE_FR] = int( data.get(sc.VS_TIRE_PRESSURE_FR) or (old_status.get(sc.TIRE_PRESSURE_FR) or 0)) status[sc.TIRE_PRESSURE_RL] = int( data.get(sc.VS_TIRE_PRESSURE_RL) or (old_status.get(sc.TIRE_PRESSURE_RL) or 0)) status[sc.TIRE_PRESSURE_RR] = int( data.get(sc.VS_TIRE_PRESSURE_RR) or (old_status.get(sc.TIRE_PRESSURE_RR) or 0)) # Not sure if these fields are ever valid (or even appear) for non security plus subscribers. They are always garbage on Crosstrek PHEV. status[sc.LOCATION_VALID] = False if data.get(sc.VS_LONGITUDE) not in [ sc.BAD_LONGITUDE, None ] and data.get(sc.VS_LATITUDE) not in [ sc.BAD_LATITUDE, None, ]: status[sc.LONGITUDE] = data.get(sc.VS_LONGITUDE) status[sc.LATITUDE] = data.get(sc.VS_LATITUDE) status[sc.HEADING] = None status[sc.LOCATION_VALID] = True self._vehicles[vin][sc.VEHICLE_STATUS].update(status) # Additional Data (Security Plus and Generation2 Required) if self.get_remote_status(vin) and self.get_api_gen( vin) == sc.FEATURE_G2_TELEMATICS: js_resp = await self._remote_query(vin, sc.API_CONDITION) if js_resp.get("success") and js_resp.get("data"): status = await self._cleanup_condition(js_resp, vin) self._vehicles[vin][sc.VEHICLE_STATUS].update(status) # Obtain lat/long from a more reliable source for Security Plus g2 await self._locate(vin) # Fetch climate presets for supported vehicles if self.get_res_status(vin) or self.get_ev_status(vin): await self._fetch_climate_presets(vin) return True async def _cleanup_condition(self, js_resp, vin): data = js_resp["data"]["result"]["data"] data[sc.TIMESTAMP] = datetime.strptime(data[sc.LAST_UPDATED_DATE], sc.TIMESTAMP_FMT).timestamp() data[sc.POSITION_TIMESTAMP] = datetime.strptime( data[sc.POSITION_TIMESTAMP], sc.POSITION_TIMESTAMP_FMT).timestamp() # Discard these values since vehicleStatus.json is always more reliable data.pop(sc.ODOMETER) data.pop(sc.AVG_FUEL_CONSUMPTION) data.pop(sc.DIST_TO_EMPTY) data.pop(sc.TIRE_PRESSURE_FL) data.pop(sc.TIRE_PRESSURE_FR) data.pop(sc.TIRE_PRESSURE_RL) data.pop(sc.TIRE_PRESSURE_RR) # check for EV specific values if self.get_ev_status(vin): if int(data.get(sc.EV_DISTANCE_TO_EMPTY) or 0) > 20: # This value is incorrectly high immediately after car shutdown data.pop(sc.EV_DISTANCE_TO_EMPTY) if int( data.get(sc.EV_TIME_TO_FULLY_CHARGED) or sc.BAD_EV_TIME_TO_FULLY_CHARGED) == int( sc.BAD_EV_TIME_TO_FULLY_CHARGED): # Value is None or known erroneous number data[sc.EV_TIME_TO_FULLY_CHARGED] = 0 # Value is correct unless it is None data[sc.EV_DISTANCE_TO_EMPTY] = int( data.get(sc.EV_DISTANCE_TO_EMPTY) or 0) # If car is charging, calculate absolute time of estimated completion if data.get(sc.EV_CHARGER_STATE_TYPE) == sc.CHARGING: finish_time = datetime.fromtimestamp(data.get( sc.TIMESTAMP)) + timedelta( minutes=int(data.get(sc.EV_TIME_TO_FULLY_CHARGED))) data[sc.EV_TIME_TO_FULLY_CHARGED_UTC] = finish_time.isoformat() else: data[sc.EV_TIME_TO_FULLY_CHARGED_UTC] = datetime.fromtimestamp( 0).isoformat() # check for other g2 known erroneous values if data.get(sc.EXTERNAL_TEMP) == sc.BAD_EXTERNAL_TEMP: data.pop(sc.EXTERNAL_TEMP) return data async def _locate(self, vin, hard_poll=False): if hard_poll: # Sends a locate command to the vehicle to get real time position if self.get_api_gen(vin) == sc.FEATURE_G2_TELEMATICS: url = sc.API_G2_LOCATE_UPDATE poll_url = sc.API_G2_LOCATE_STATUS else: url = sc.API_G1_LOCATE_UPDATE poll_url = sc.API_G1_LOCATE_STATUS success, js_resp = await self._remote_command(vin, url, poll_url=poll_url) else: # Reports the last location the vehicle has reported to Subaru js_resp = await self._remote_query(vin, sc.API_LOCATE) success = js_resp.get("success") if success and js_resp.get("success"): self._parse_location(vin, js_resp["data"]["result"]) return True def _parse_location(self, vin, result): if result[sc.LONGITUDE] == sc.BAD_LONGITUDE and result[ sc.LATITUDE] == sc.BAD_LATITUDE: # After car shutdown, some vehicles will push an update to Subaru with an invalid location. In this case keep previous and set flag so app knows to request update. self._vehicles[vin][sc.VEHICLE_STATUS][ sc.LONGITUDE] = self._vehicles[vin][sc.VEHICLE_STATUS].get( sc.LONGITUDE) self._vehicles[vin][sc.VEHICLE_STATUS][ sc.LATITUDE] = self._vehicles[vin][sc.VEHICLE_STATUS].get( sc.LATITUDE) self._vehicles[vin][sc.VEHICLE_STATUS][ sc.HEADING] = self._vehicles[vin][sc.VEHICLE_STATUS].get( sc.HEADING) self._vehicles[vin][sc.VEHICLE_STATUS][sc.LOCATION_VALID] = False else: self._vehicles[vin][sc.VEHICLE_STATUS][sc.LONGITUDE] = result.get( sc.LONGITUDE) self._vehicles[vin][sc.VEHICLE_STATUS][sc.LATITUDE] = result.get( sc.LATITUDE) self._vehicles[vin][sc.VEHICLE_STATUS][sc.HEADING] = result.get( sc.HEADING) self._vehicles[vin][sc.VEHICLE_STATUS][sc.LOCATION_VALID] = True async def _wait_request_status(self, vin, req_id, poll_url, attempts=20): params = {sc.SERVICE_REQ_ID: req_id} attempts_left = attempts _LOGGER.debug( "Polling for remote service request completion: serviceRequestId=%s", req_id) while attempts_left > 0: js_resp = await self._get(poll_url.replace("api_gen", self.get_api_gen(vin)), params=params) _LOGGER.debug(pprint.pformat(js_resp)) if js_resp["errorCode"] in [ sc.ERROR_SOA_403, sc.ERROR_INVALID_TOKEN ]: await self._connection.validate_session(vin) continue if js_resp["data"]["remoteServiceState"] == "finished": if js_resp["data"]["success"]: _LOGGER.info( "Remote service request completed successfully: %s", req_id) return True, js_resp _LOGGER.error( "Remote service request completed but failed: %s Error: %s", req_id, js_resp["data"]["errorCode"], ) raise RemoteServiceFailure( "Remote service request completed but failed: %s" % js_resp["data"]["errorCode"]) if js_resp["data"].get("remoteServiceState") == "started": _LOGGER.info( "Subaru API reports remote service request is in progress: %s", req_id, ) attempts_left -= 1 await asyncio.sleep(2) continue _LOGGER.error( "Remote service request completion message never received") raise RemoteServiceFailure( "Remote service request completion message never received") async def _fetch_climate_presets(self, vin): vin = vin.upper() if self.get_res_status(vin) or self.get_ev_status(vin): presets = [] # Fetch STARLINK Presets js_resp = await self._get(sc.API_G2_FETCH_RES_SUBARU_PRESETS) _LOGGER.debug(pprint.pformat(js_resp)) built_in_presets = [json.loads(i) for i in js_resp["data"]] for i in built_in_presets: if self.get_ev_status(vin) and i["vehicleType"] == "phev": presets.append(i) elif not self.get_ev_status(vin) and i["vehicleType"] == "gas": presets.append(i) # Fetch User Defined Presets js_resp = await self._get(sc.API_G2_FETCH_RES_USER_PRESETS) _LOGGER.debug(pprint.pformat(js_resp)) data = js_resp[ "data"] # data is None is user has not configured any presets if isinstance(data, str): for i in json.loads(data): presets.append(i) self._vehicles[vin]["climate"] = presets return True raise VehicleNotSupported( "Active STARLINK Security Plus subscription required.") def _validate_remote_start_params(self, vin, preset_data): is_valid = True err_msg = None try: for item in preset_data: if preset_data[item] not in sc.VALID_CLIMATE_OPTIONS[item]: if item == "name" and isinstance(preset_data[item], str): continue is_valid = False err_msg = f"Invalid value for {item}: {preset_data[item]}" break except KeyError as err: is_valid = False err_msg = f"Invalid option: {err}" if not is_valid: raise SubaruException(err_msg) if self.get_ev_status(vin): preset_data.update(sc.START_CONFIG_CONSTS_EV) else: preset_data.update(sc.START_CONFIG_CONSTS_RES) return is_valid def _validate_remote_capability(self, vin): if not self.get_res_status(vin) and not self.get_ev_status(vin): raise VehicleNotSupported( "Active STARLINK Security Plus subscription and remote start capable vehicle required." ) return True
class Controller: """Controller to interact with Subaru Starlink mobile app API.""" def __init__( self, websession, username, password, device_id, pin, device_name, update_interval=sc.DEFAULT_UPDATE_INTERVAL, fetch_interval=sc.DEFAULT_FETCH_INTERVAL, ): """Initialize controller. Args: websession (aiohttp.ClientSession): An instance of aiohttp.ClientSession. username (str): Username used for the MySubaru mobile app. password (str): Password used for the MySubaru mobile app. device_id (str): Alphanumeric designator that Subaru API uses to track individual device authorization. pin (str): 4 digit pin number required to send remote vehicle commands. device_name (str): Human friendly name that is associated with `device_id` (shows on mysubaru.com profile "devices"). update_interval (int, optional): Seconds between requests for vehicle send update fetch_interval (int, optional): Seconds between fetches of Subaru's cached vehicle information """ self._connection = Connection(websession, username, password, device_id, device_name) self._update_interval = update_interval self._fetch_interval = fetch_interval self._vehicles = {} self._pin = pin self._controller_lock = asyncio.Lock() self._pin_lockout = False async def connect(self, test_login=False): """ Connect to Subaru Remote Services API. Args: test_login (bool, optional): If `True` then username/password is verified only. Returns: bool: `True` if success, `False` if failure Raises: InvalidCredentials: If login credentials are incorrect. IncompleteCredentials: If login credentials were not provided. SubaruException: If authorization and registration sequence fails for any other reason. """ _LOGGER.debug("Connecting controller to Subaru Remote Services") vehicle_list = await self._connection.connect(test_login=test_login) if vehicle_list is None: raise SubaruException("Connection to Subaru API failed") if not test_login: for vehicle in vehicle_list: self._parse_vehicle(vehicle) _LOGGER.debug("Subaru Remote Services Ready!") return True async def test_pin(self): """ Tests if stored PIN is valid for Remote Services. Returns: bool: `True` if PIN is correct. `False` if no vehicle with remote capability exists in account. Raises: InvalidPIN: If PIN is incorrect. SubaruException: If other failure occurs. """ _LOGGER.info("Testing PIN for validity with Subaru remote services") for vin in self._vehicles: if self.get_remote_status(vin): await self._connection.validate_session(vin) api_gen = self.get_api_gen(vin) form_data = {"pin": self._pin} test_path = sc.API_G1_LOCATE_UPDATE if api_gen == sc.FEATURE_G1_TELEMATICS else sc.API_G2_LOCATE_UPDATE async with self._vehicles[vin][sc.VEHICLE_LOCK]: js_resp = await self._post(test_path, json_data=form_data) _LOGGER.debug(pprint.pformat(js_resp)) if js_resp["success"]: _LOGGER.info("PIN is valid for Subaru remote services") return True _LOGGER.info( "No active vehicles with remote services subscription - PIN not required" ) return False def get_vehicles(self): """ Return list of VINs available to user on Subaru Remote Services API. Returns: List: A list containing the VINs of all vehicles registered to the Subaru account. """ return list(self._vehicles.keys()) def get_ev_status(self, vin): """ Get whether the specified VIN is an Electric Vehicle. Args: vin (str): The VIN to check. Returns: bool: `True` if `vin` is an Electric Vehicle, `False` if not. None: If `vin` is invalid. """ vehicle = self._vehicles.get(vin.upper()) status = None if vehicle: status = sc.FEATURE_PHEV in vehicle[sc.VEHICLE_FEATURES] _LOGGER.debug("Getting EV Status %s:%s", vin, status) return status def get_remote_status(self, vin): """ Get whether the specified VIN has remote locks/horn/light service available. Args: vin (str): The VIN to check. Returns: bool: `True` if `vin` has remote capability and an active service plan, `False` if not. None: If `vin` is invalid. """ vehicle = self._vehicles.get(vin.upper()) status = None if vehicle: status = sc.FEATURE_REMOTE in vehicle[ sc. VEHICLE_SUBSCRIPTION_FEATURES] and self.get_subscription_status( vin) _LOGGER.debug("Getting remote Status %s:%s", vin, status) return status def get_res_status(self, vin): """ Get whether the specified VIN has remote engine start service available. Args: vin (str): The VIN to check. Returns: bool: `True` if `vin` has remote engine (or EV) start capability and an active service plan, `False` if not. None: If `vin` is invalid. """ vehicle = self._vehicles.get(vin.upper()) status = None if vehicle: status = sc.FEATURE_REMOTE_START in vehicle[ sc.VEHICLE_FEATURES] and self.get_remote_status(vin) _LOGGER.debug("Getting RES Status %s:%s", vin, status) return status def get_safety_status(self, vin): """ Get whether the specified VIN is has an active Starlink Safety Plus service plan. Args: vin (str): The VIN to check. Returns: bool: `True` if `vin` has an active Safety Plus service plan, `False` if not. None: If `vin` is invalid. """ vehicle = self._vehicles.get(vin.upper()) status = None if vehicle: status = sc.FEATURE_SAFETY in vehicle[ sc. VEHICLE_SUBSCRIPTION_FEATURES] and self.get_subscription_status( vin) _LOGGER.debug("Getting Safety Plus Status %s:%s", vin, status) return status def get_subscription_status(self, vin): """ Get whether the specified VIN has an active service plan. Args: vin (str): The VIN to check. Returns: bool: `True` if `vin` has an active service plan, `False` if not. None: If `vin` is invalid. """ vehicle = self._vehicles.get(vin.upper()) status = None if vehicle: status = vehicle[ sc.VEHICLE_SUBSCRIPTION_STATUS] == sc.FEATURE_ACTIVE _LOGGER.debug("Getting subscription Status %s:%s", vin, status) return status def get_api_gen(self, vin): """ Get the Subaru telematics API generation of a specified VIN. Args: vin (str): The VIN to check. Returns: str: `subarulink.const.FEATURE_G1_TELEMATICS` or `subarulink.const.FEATURE_G2_TELEMATICS` None: If `vin` is invalid. """ vehicle = self._vehicles.get(vin.upper()) result = None if vehicle: if sc.FEATURE_G1_TELEMATICS in vehicle[sc.VEHICLE_FEATURES]: result = sc.FEATURE_G1_TELEMATICS if sc.FEATURE_G2_TELEMATICS in vehicle[sc.VEHICLE_FEATURES]: result = sc.FEATURE_G2_TELEMATICS _LOGGER.debug("Getting vehicle API gen %s:%s", vin, result) return result def vin_to_name(self, vin): """ Get the nickname of a specified VIN. Args: vin (str): The VIN to check. Returns: str: Display name associated with `vin` None: If `vin` is invalid. """ vehicle = self._vehicles.get(vin.upper()) result = None if vehicle: result = vehicle[sc.VEHICLE_NAME] return result async def get_data(self, vin): """ Get locally cached vehicle data. Fetch from Subaru API if not present. Args: vin (str): The VIN to get. Returns: dict: Vehicle information. None: If `vin` is invalid. Raises: SubaruException: If fetch operation fails. """ vehicle = self._vehicles.get(vin.upper()) result = None if vehicle: if len(vehicle.get(sc.VEHICLE_STATUS)) == 0: await self.fetch(vin) result = self._vehicles[vin.upper()] return result async def get_climate_settings(self, vin): """ Fetch saved climate control settings from Subaru API. Args: vin (str): The VIN to get. Returns: bool: `True` upon success. Settings are not returned by this function. Use `get_data()` to retrieve. None: If `vin` is invalid. Raises: SubaruException: If failure prevents a valid response from being received. """ vin = vin.upper() if self.get_res_status(vin) or self.get_ev_status(vin): await self._connection.validate_session(vin) js_resp = await self._get(sc.API_G2_FETCH_CLIMATE_SETTINGS) _LOGGER.debug(js_resp) self._vehicles[vin]["climate"] = json.loads(js_resp["data"]) return True async def save_climate_settings(self, vin, form_data): """ Save climate control settings to Subaru. Args: vin (str): The VIN to save climate settings to. form_data (dict): Climate settings to save. Returns: bool: `True` upon success. Settings are not returned by this function. Use `get_data()` to retrieve. None: If `vin` is invalid, unsupported, or climate settings invalid. Raises: SubaruException: If failure prevents a valid response from being received. """ vin = vin.upper() if self.get_res_status(vin) or self.get_ev_status(vin): if self._validate_remote_start_params(vin, form_data): await self._connection.validate_session(vin) js_resp = await self._post(sc.API_G2_SAVE_CLIMATE_SETTINGS, json_data=form_data) _LOGGER.debug(js_resp) self._vehicles[vin]["climate"] = js_resp["data"] _LOGGER.info("Climate control settings saved.") return True async def fetch(self, vin, force=False): """ Fetch latest vehicle status data cached on Subaru servers. Args: vin (str): The VIN to fetch. force (bool, optional): Override `fetch_interval` value and force a query. Returns: bool: `True` upon success. Status is not returned by this function. Use `get_data()` to retrieve. None: If `vin` is invalid, unsupported, or `fetch_interval` not met. Raises: SubaruException: If failure prevents a valid response from being received. """ vin = vin.upper() if self.get_safety_status(vin): async with self._controller_lock: last_fetch = self.get_last_fetch_time(vin) cur_time = time.time() if force or cur_time - last_fetch > self._fetch_interval: result = await self._fetch_status(vin) self._vehicles[vin][sc.VEHICLE_LAST_FETCH] = cur_time return result async def update(self, vin, force=False): """ Initiate remote service command to update vehicle status. Args: vin (str): The VIN to update. force (bool, optional): Override `update_interval` value and force a query. Returns: bool: `True` upon success. Status is not returned by this function. Use `fetch()` then `get_data()` to retrieve. None: If `vin` is invalid, unsupported, or `update_interval` not met. Raises: SubaruException: If failure prevents a valid response from being received. """ vin = vin.upper() if self.get_remote_status(vin): async with self._controller_lock: last_update = self.get_last_update_time(vin) cur_time = time.time() if force or cur_time - last_update > self._update_interval: result = await self._locate(vin, hard_poll=True) self._vehicles[vin][sc.VEHICLE_LAST_UPDATE] = cur_time return result def get_update_interval(self): """Get current update interval.""" return self._update_interval def set_update_interval(self, value): """ Set new update interval. Args: value (int): New update interval in seconds. Returns: bool: `True` if update succeeded, `False` if update failed. """ old_interval = self._update_interval if value >= 300: self._update_interval = value _LOGGER.debug("Update interval changed from %s to %s", old_interval, value) return True _LOGGER.error("Invalid update interval %s. Keeping old value: %s", value, old_interval) return False def get_fetch_interval(self): """Get current fetch interval.""" return self._fetch_interval def set_fetch_interval(self, value): """ Set new fetch interval. Args: value (int): New fetch interval in seconds. Returns: bool: `True` if update succeeded, `False` if update failed. """ old_interval = self._fetch_interval if value >= 60: self._fetch_interval = value _LOGGER.debug("Fetch interval changed from %s to %s", old_interval, value) return True _LOGGER.error("Invalid fetch interval %s. Keeping old value: %s", value, old_interval) return False def get_last_fetch_time(self, vin): """ Get last time data was fetched for a specific VIN. Args: vin (str): VIN to check. Returns: float: timestamp of last update() None: if `vin` is invalid. """ result = None vehicle = self._vehicles.get(vin.upper()) if vehicle: result = vehicle[sc.VEHICLE_LAST_FETCH] return result def get_last_update_time(self, vin): """ Get last time update remote command was used on a specific VIN. Args: vin (str): VIN to check. Returns: float: timestamp of last update() None: if `vin` is invalid. """ result = None vehicle = self._vehicles.get(vin.upper()) if vehicle: result = vehicle[sc.VEHICLE_LAST_UPDATE] return result async def charge_start(self, vin): """ Send command to start EV charging. Args: vin (str): Destination VIN for command. Returns: bool: `True` upon success. `False` upon failure. Raises: InvalidPIN: if PIN is incorrect. SubaruException: for all other failures. """ success, _ = await self._remote_command(vin.upper(), sc.API_EV_CHARGE_NOW) return success async def lock(self, vin): """ Send command to lock doors. Args: vin (str): Destination VIN for command. Returns: bool: `True` upon success. `False` upon failure. Raises: InvalidPIN: if PIN is incorrect. SubaruException: for all other failures. """ form_data = {"forceKeyInCar": False} success, _ = await self._actuate(vin.upper(), sc.API_LOCK, data=form_data) return success async def unlock(self, vin, only_driver=True): """ Send command to unlock doors. Args: vin (str): Destination VIN for command. only_driver (bool, optional): Only unlock driver's door if `True`. Returns: bool: `True` upon success. `False` upon failure. Raises: InvalidPIN: if PIN is incorrect. SubaruException: for all other failures. """ door = sc.ALL_DOORS if only_driver: door = sc.DRIVERS_DOOR form_data = {sc.WHICH_DOOR: door} success, _ = await self._actuate(vin.upper(), sc.API_UNLOCK, data=form_data) return success async def lights(self, vin): """ Send command to flash lights. Args: vin (str): Destination VIN for command. Returns: bool: `True` upon success. `False` upon failure. Raises: InvalidPIN: if PIN is incorrect. SubaruException: for all other failures. """ success, _ = await self._actuate(vin.upper(), sc.API_LIGHTS) return success async def horn(self, vin): """ Send command to sound horn. Args: vin (str): Destination VIN for command. Returns: bool: `True` upon success. `False` upon failure. Raises: InvalidPIN: if PIN is incorrect. SubaruException: for all other failures. """ success, _ = await self._actuate(vin.upper(), sc.API_HORN_LIGHTS) return success async def remote_stop(self, vin): """ Send command to stop engine. Args: vin (str): Destination VIN for command. Returns: bool: `True` upon success. `False` upon failure. Raises: InvalidPIN: if PIN is incorrect. SubaruException: for all other failures. """ success, _ = await self._actuate(vin.upper(), sc.API_G2_REMOTE_ENGINE_STOP) return success async def remote_start(self, vin, form_data=None): """ Send command to start engine. Args: vin (str): Destination VIN for command. form_data (dict, optional): Climate control settings Returns: bool: `True` upon success. `False` upon failure. Raises: InvalidPIN: if PIN is incorrect. SubaruException: for all other failures. """ vin = vin.upper() if self.get_res_status(vin) or self.get_ev_status(vin): if form_data: if self._validate_remote_start_params(vin, form_data): climate_settings = form_data else: raise SubaruException("Error with climate settings") else: await self.get_climate_settings(vin) climate_settings = self._vehicles[vin]["climate"] if climate_settings: success, _ = await self._actuate(vin, sc.API_G2_REMOTE_ENGINE_START, data=climate_settings) return success raise SubaruException("Remote Start not supported for this vehicle") def invalid_pin_entered(self): """Return if invalid PIN error was received, thus locking out further remote commands.""" return self._pin_lockout def update_saved_pin(self, new_pin): """ Update the saved PIN used by the controller. Args: new_pin (str): New 4 digit PIN. Returns: bool: `True` if PIN was updated, otherwise `False` """ if new_pin != self._pin: self._pin = new_pin self._pin_lockout = False return True return False async def _get(self, url, params=None): js_resp = await self._connection.get(url, params) self._check_error_code(js_resp) return js_resp async def _post(self, url, params=None, json_data=None): js_resp = await self._connection.post(url, params, json_data) self._check_error_code(js_resp) return js_resp def _check_error_code(self, js_resp): error = js_resp.get("errorCode") if error == sc.ERROR_SOA_403: _LOGGER.debug("SOA 403 error - clearing session cookie") self._connection.reset_session() elif error == sc.ERROR_INVALID_CREDENTIALS: _LOGGER.error("PIN is not valid for Subaru remote services") self._pin_lockout = True raise InvalidPIN("Invalid PIN! %s" % js_resp["data"]["errorDescription"]) elif error == sc.ERROR_SERVICE_ALREADY_STARTED: pass elif error: _LOGGER.error("Unhandled API error code %s", error) raise SubaruException("Unhandled API error: {} - {}".format( error, js_resp["data"]["errorDescription"])) def _parse_vehicle(self, vehicle): vin = vehicle["vin"].upper() _LOGGER.debug("Parsing vehicle: %s", vin) self._vehicles[vin] = { sc.VEHICLE_NAME: vehicle[sc.VEHICLE_NAME], sc.VEHICLE_LOCK: asyncio.Lock(), sc.VEHICLE_LAST_FETCH: 0, sc.VEHICLE_LAST_UPDATE: 0, sc.VEHICLE_STATUS: {}, sc.VEHICLE_FEATURES: vehicle[sc.VEHICLE_FEATURES], sc.VEHICLE_SUBSCRIPTION_FEATURES: vehicle[sc.VEHICLE_SUBSCRIPTION_FEATURES], sc.VEHICLE_SUBSCRIPTION_STATUS: vehicle[sc.VEHICLE_SUBSCRIPTION_STATUS], } async def _remote_query(self, vin, cmd): tries_left = 2 js_resp = None while tries_left > 0: await self._connection.validate_session(vin) api_gen = self.get_api_gen(vin) async with self._vehicles[vin][sc.VEHICLE_LOCK]: js_resp = await self._get(cmd.replace("api_gen", api_gen)) _LOGGER.debug(pprint.pformat(js_resp)) if js_resp["success"]: return js_resp if js_resp["errorCode"] == sc.ERROR_SOA_403: tries_left -= 1 else: tries_left = 0 raise SubaruException("Remote query failed. Response: %s " % js_resp) async def _remote_command(self, vin, cmd, data=None, poll_url=sc.API_REMOTE_SVC_STATUS): try_again = True while try_again: if not self._pin_lockout: await self._connection.validate_session(vin) async with self._vehicles[vin][sc.VEHICLE_LOCK]: try_again, success, js_resp = await self._execute_remote_command( vin, cmd, data, poll_url) if success: return success, js_resp else: raise PINLockoutProtect( "Remote command with invalid PIN cancelled to prevent account lockout" ) return False, None async def _execute_remote_command(self, vin, cmd, data, poll_url): try_again = False success = None api_gen = self.get_api_gen(vin) form_data = {"pin": self._pin} if data: form_data.update(data) js_resp = await self._post(cmd.replace("api_gen", api_gen), json_data=form_data) _LOGGER.debug(pprint.pformat(js_resp)) if api_gen == FEATURE_G2_TELEMATICS: if js_resp["errorCode"] == sc.ERROR_SOA_403: try_again = True if js_resp["success"]: req_id = js_resp["data"][sc.SERVICE_REQ_ID] success, js_resp = await self._wait_request_status_g2( req_id, poll_url) else: if js_resp.get(sc.SERVICE_REQ_ID): req_id = js_resp[sc.SERVICE_REQ_ID] success, js_resp = await self._wait_request_status_g1( req_id, poll_url) return try_again, success, js_resp async def _actuate(self, vin, cmd, data=None): form_data = {"delay": 0, "vin": vin} if data: form_data.update(data) if self.get_remote_status(vin): return await self._remote_command(vin, cmd, data=form_data) raise SubaruException("Command requires REMOTE subscription.") async def _get_vehicle_status(self, vin): await self._connection.validate_session(vin) js_resp = await self._get(sc.API_VEHICLE_STATUS) _LOGGER.debug(pprint.pformat(js_resp)) return js_resp async def _fetch_status(self, vin): _LOGGER.debug("Fetching vehicle status from Subaru") js_resp = await self._get_vehicle_status(vin) if js_resp.get("success") and js_resp.get("data"): data = js_resp["data"] old_status = self._vehicles[vin][sc.VEHICLE_STATUS] status = {} # These values seem to always be valid status[sc.ODOMETER] = data.get(sc.VS_ODOMETER) status[sc.TIMESTAMP] = data.get(sc.VS_TIMESTAMP) / 1000 status[sc.AVG_FUEL_CONSUMPTION] = data.get( sc.VS_AVG_FUEL_CONSUMPTION) status[sc.DIST_TO_EMPTY] = data.get(sc.VS_DIST_TO_EMPTY) status[sc.VEHICLE_STATE] = data.get(sc.VS_VEHICLE_STATE) # Tire pressure is either valid or None. If None and we have a previous value, keep previous, otherwise 0. status[sc.TIRE_PRESSURE_FL] = int( data.get(sc.VS_TIRE_PRESSURE_FL) or (old_status.get(sc.TIRE_PRESSURE_FL) or 0)) status[sc.TIRE_PRESSURE_FR] = int( data.get(sc.VS_TIRE_PRESSURE_FR) or (old_status.get(sc.TIRE_PRESSURE_FL) or 0)) status[sc.TIRE_PRESSURE_RL] = int( data.get(sc.VS_TIRE_PRESSURE_RL) or (old_status.get(sc.TIRE_PRESSURE_FL) or 0)) status[sc.TIRE_PRESSURE_RR] = int( data.get(sc.VS_TIRE_PRESSURE_RR) or (old_status.get(sc.TIRE_PRESSURE_FL) or 0)) # Not sure if these fields are ever valid (or even appear) for non security plus subscribers. They are always garbage on Crosstrek PHEV. if not self.get_remote_status(vin): status[sc.LONGITUDE] = data.get(sc.VS_LONGITUDE) status[sc.LATITUDE] = data.get(sc.VS_LATITUDE) status[sc.HEADING] = data.get(sc.VS_HEADING) status[sc.LOCATION_VALID] = True if status[sc.LONGITUDE] in [sc.BAD_LONGITUDE, None ] and status[sc.LATITUDE] in [ sc.BAD_LATITUDE, None, ]: status[sc.LOCATION_VALID] = False self._vehicles[vin][sc.VEHICLE_STATUS].update(status) # Additional Data (Security Plus Required) if self.get_remote_status(vin): js_resp = await self._remote_query(vin, sc.API_CONDITION) if js_resp.get("success") and js_resp.get("data"): status = await self._cleanup_condition(js_resp, vin) self._vehicles[vin][sc.VEHICLE_STATUS].update(status) return True async def _cleanup_condition(self, js_resp, vin): data = {} try: # Annoying key/value pair format [{"key": key, "value": value}, ...] data = { i["key"]: i["value"] for i in js_resp["data"]["result"]["vehicleStatus"] } data[sc.TIMESTAMP] = datetime.strptime( js_resp["data"]["result"]["lastUpdatedTime"], sc.TIMESTAMP_FMT).timestamp() data[sc.POSITION_TIMESTAMP] = datetime.strptime( data[sc.POSITION_TIMESTAMP], sc.POSITION_TIMESTAMP_FMT).timestamp() # Discard these values since vehicleStatus.json is always more reliable data.pop(sc.ODOMETER) data.pop(sc.AVG_FUEL_CONSUMPTION) data.pop(sc.DIST_TO_EMPTY) data.pop(sc.TIRE_PRESSURE_FL) data.pop(sc.TIRE_PRESSURE_FR) data.pop(sc.TIRE_PRESSURE_RL) data.pop(sc.TIRE_PRESSURE_RR) except KeyError: # Once in a while a 'value' key or some other field is missing pass if self.get_ev_status(vin): if int(data.get(sc.EV_DISTANCE_TO_EMPTY) or 0) > 20: # This value is incorrectly high immediately after car shutdown data.pop(sc.EV_DISTANCE_TO_EMPTY) if int( data.get(sc.EV_TIME_TO_FULLY_CHARGED) or sc.BAD_EV_TIME_TO_FULLY_CHARGED) == int( sc.BAD_EV_TIME_TO_FULLY_CHARGED): # Value is None or known erroneous number data[sc.EV_TIME_TO_FULLY_CHARGED] = 0 # Value is correct unless it is None data[sc.EV_DISTANCE_TO_EMPTY] = int( data.get(sc.EV_DISTANCE_TO_EMPTY) or 0) # Obtain lat/long from a more reliable source for Security Plus subscribers await self._locate(vin) return data async def _locate(self, vin, hard_poll=False): if hard_poll: # Sends a locate command to the vehicle to get real time position if self.get_api_gen(vin) == sc.FEATURE_G2_TELEMATICS: url = sc.API_G2_LOCATE_UPDATE poll_url = sc.API_G2_LOCATE_STATUS else: url = sc.API_G1_LOCATE_UPDATE poll_url = sc.API_G1_LOCATE_STATUS success, js_resp = await self._remote_command(vin, url, poll_url=poll_url) else: # Reports the last location the vehicle has reported to Subaru js_resp = await self._remote_query(vin, sc.API_LOCATE) success = js_resp.get("success") if success and js_resp.get("success"): self._parse_location(vin, js_resp["data"]["result"]) return True if success and js_resp.get("status"): self._parse_location(vin, js_resp["result"]) return True def _parse_location(self, vin, result): if result[sc.LONGITUDE] == sc.BAD_LONGITUDE and result[ sc.LATITUDE] == sc.BAD_LATITUDE: # After car shutdown, some vehicles will push an update to Subaru with an invalid location. In this case keep previous and set flag so app knows to request update. self._vehicles[vin][sc.VEHICLE_STATUS][ sc.LONGITUDE] = self._vehicles[vin][sc.VEHICLE_STATUS].get( sc.LONGITUDE) self._vehicles[vin][sc.VEHICLE_STATUS][ sc.LATITUDE] = self._vehicles[vin][sc.VEHICLE_STATUS].get( sc.LATITUDE) self._vehicles[vin][sc.VEHICLE_STATUS][ sc.HEADING] = self._vehicles[vin][sc.VEHICLE_STATUS].get( sc.HEADING) self._vehicles[vin][sc.VEHICLE_STATUS][sc.LOCATION_VALID] = False else: self._vehicles[vin][sc.VEHICLE_STATUS][sc.LONGITUDE] = result.get( sc.LONGITUDE) self._vehicles[vin][sc.VEHICLE_STATUS][sc.LATITUDE] = result.get( sc.LATITUDE) self._vehicles[vin][sc.VEHICLE_STATUS][sc.HEADING] = result.get( sc.HEADING) self._vehicles[vin][sc.VEHICLE_STATUS][sc.LOCATION_VALID] = True async def _wait_request_status_g2(self, req_id, poll_url, attempts=20): params = {sc.SERVICE_REQ_ID: req_id} attempts_left = attempts _LOGGER.debug( "Polling for remote service request completion: serviceRequestId=%s", req_id) while attempts_left > 0: js_resp = await self._get(poll_url.replace( "api_gen", sc.FEATURE_G2_TELEMATICS), params=params) _LOGGER.debug(pprint.pformat(js_resp)) if js_resp["data"]["remoteServiceState"] == "finished": if js_resp["data"]["success"]: _LOGGER.info( "Remote service request completed successfully: %s", req_id) return True, js_resp _LOGGER.error( "Remote service request completed but failed: %s Error: %s", req_id, js_resp["data"]["errorCode"], ) return False, js_resp if js_resp["data"].get("remoteServiceState") == "started": _LOGGER.info( "Subaru API reports remote service request is in progress: %s", req_id, ) attempts_left -= 1 await asyncio.sleep(2) continue _LOGGER.error("Remote service request completion message not received") return False, None async def _wait_request_status_g1(self, req_id, poll_url, attempts=20): params = {sc.SERVICE_REQ_ID: req_id} attempts_left = attempts _LOGGER.debug( "Polling for remote service request completion: serviceRequestId=%s", req_id) while attempts_left > 0: js_resp = await self._get(poll_url.replace( "api_gen", sc.FEATURE_G1_TELEMATICS), params=params) _LOGGER.debug(pprint.pformat(js_resp)) if js_resp["status"] == "SUCCESS": _LOGGER.info( "Remote service request completed successfully: %s", req_id) return True, js_resp if js_resp["status"] == "PENDING": _LOGGER.info( "Subaru API reports remote service request is in progress: %s", req_id, ) attempts_left -= 1 await asyncio.sleep(2) continue _LOGGER.error("Remote service request completion message not received") return False, None def _validate_remote_start_params(self, vin, form_data): try: temp = int(form_data[sc.TEMP]) is_valid = True if temp > sc.TEMP_MAX or temp < sc.TEMP_MIN: is_valid = False if form_data[sc.MODE] not in [ sc.MODE_AUTO, sc.MODE_DEFROST, sc.MODE_FACE, sc.MODE_FEET, sc.MODE_FEET_DEFROST, sc.MODE_SPLIT, ]: is_valid = False if form_data[sc.HEAT_SEAT_LEFT] not in [ sc.HEAT_SEAT_OFF, sc.HEAT_SEAT_HI, sc.HEAT_SEAT_MED, sc.HEAT_SEAT_LOW, ]: is_valid = False if form_data[sc.HEAT_SEAT_RIGHT] not in [ sc.HEAT_SEAT_OFF, sc.HEAT_SEAT_HI, sc.HEAT_SEAT_MED, sc.HEAT_SEAT_LOW, ]: is_valid = False if form_data[sc.REAR_DEFROST] not in [ sc.REAR_DEFROST_OFF, sc.REAR_DEFROST_ON, ]: is_valid = False if form_data[sc.FAN_SPEED] not in [ sc.FAN_SPEED_AUTO, sc.FAN_SPEED_HI, sc.FAN_SPEED_LOW, sc.FAN_SPEED_MED, ]: is_valid = False if form_data[sc.RECIRCULATE] not in [ sc.RECIRCULATE_OFF, sc.RECIRCULATE_ON ]: is_valid = False if form_data[sc.REAR_AC] not in [sc.REAR_AC_OFF, sc.REAR_AC_ON]: is_valid = False form_data[sc.RUNTIME] = sc.RUNTIME_DEFAULT form_data[sc.CLIMATE] = sc.CLIMATE_DEFAULT if self.get_ev_status(vin): form_data[sc.START_CONFIG] = sc.START_CONFIG_DEFAULT_EV elif self.get_res_status(vin): form_data[sc.START_CONFIG] = sc.START_CONFIG_DEFAULT_RES return is_valid except KeyError: return None