diff --git a/.coveragerc b/.coveragerc index de5bad9c077..7164dd3d35e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -646,6 +646,7 @@ omit = homeassistant/components/spotcrime/sensor.py homeassistant/components/spotify/media_player.py homeassistant/components/squeezebox/media_player.py + homeassistant/components/starline/* homeassistant/components/starlingbank/sensor.py homeassistant/components/steam_online/sensor.py homeassistant/components/stiebel_eltron/* diff --git a/CODEOWNERS b/CODEOWNERS index c9ef0123b22..a489786f48f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -292,6 +292,7 @@ homeassistant/components/spaceapi/* @fabaff homeassistant/components/speedtestdotnet/* @rohankapoorcom homeassistant/components/spider/* @peternijssen homeassistant/components/sql/* @dgomes +homeassistant/components/starline/* @anonym-tsk homeassistant/components/statistics/* @fabaff homeassistant/components/stiebel_eltron/* @fucm homeassistant/components/stream/* @hunterjm diff --git a/homeassistant/components/starline/.translations/en.json b/homeassistant/components/starline/.translations/en.json new file mode 100644 index 00000000000..afe8f8c732b --- /dev/null +++ b/homeassistant/components/starline/.translations/en.json @@ -0,0 +1,42 @@ +{ + "config": { + "error": { + "error_auth_app": "Incorrect application id or secret", + "error_auth_mfa": "Incorrect code", + "error_auth_user": "Incorrect username or password" + }, + "step": { + "auth_app": { + "data": { + "app_id": "App ID", + "app_secret": "Secret" + }, + "description": "Application ID and secret code from StarLine developer account", + "title": "Application credentials" + }, + "auth_captcha": { + "data": { + "captcha_code": "Code from image" + }, + "description": "{captcha_img}", + "title": "Captcha" + }, + "auth_mfa": { + "data": { + "mfa_code": "SMS code" + }, + "description": "Enter the code sent to phone {phone_number}", + "title": "Two-factor authorization" + }, + "auth_user": { + "data": { + "password": "Password", + "username": "Username" + }, + "description": "StarLine account email and password", + "title": "User credentials" + } + }, + "title": "StarLine" + } +} \ No newline at end of file diff --git a/homeassistant/components/starline/__init__.py b/homeassistant/components/starline/__init__.py new file mode 100644 index 00000000000..22772282a7c --- /dev/null +++ b/homeassistant/components/starline/__init__.py @@ -0,0 +1,85 @@ +"""The StarLine component.""" +import voluptuous as vol +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import Config, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .account import StarlineAccount +from .const import ( + DOMAIN, + PLATFORMS, + SERVICE_UPDATE_STATE, + SERVICE_SET_SCAN_INTERVAL, + CONF_SCAN_INTERVAL, + DEFAULT_SCAN_INTERVAL, +) + + +async def async_setup(hass: HomeAssistant, config: Config) -> bool: + """Set up configured StarLine.""" + return True + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Set up the StarLine device from a config entry.""" + account = StarlineAccount(hass, config_entry) + await account.update() + if not account.api.available: + raise ConfigEntryNotReady + + if DOMAIN not in hass.data: + hass.data[DOMAIN] = {} + hass.data[DOMAIN][config_entry.entry_id] = account + + device_registry = await hass.helpers.device_registry.async_get_registry() + for device in account.api.devices.values(): + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, **account.device_info(device) + ) + + for domain in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, domain) + ) + + async def async_set_scan_interval(call): + """Service for set scan interval.""" + options = dict(config_entry.options) + options[CONF_SCAN_INTERVAL] = call.data[CONF_SCAN_INTERVAL] + hass.config_entries.async_update_entry(entry=config_entry, options=options) + + hass.services.async_register(DOMAIN, SERVICE_UPDATE_STATE, account.update) + hass.services.async_register( + DOMAIN, + SERVICE_SET_SCAN_INTERVAL, + async_set_scan_interval, + schema=vol.Schema( + { + vol.Required(CONF_SCAN_INTERVAL): vol.All( + vol.Coerce(int), vol.Range(min=10) + ) + } + ), + ) + + config_entry.add_update_listener(async_options_updated) + await async_options_updated(hass, config_entry) + + return True + + +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Unload a config entry.""" + for domain in PLATFORMS: + await hass.config_entries.async_forward_entry_unload(config_entry, domain) + + account: StarlineAccount = hass.data[DOMAIN][config_entry.entry_id] + account.unload() + return True + + +async def async_options_updated(hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Triggered by config entry options updates.""" + account: StarlineAccount = hass.data[DOMAIN][config_entry.entry_id] + scan_interval = config_entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + account.set_update_interval(scan_interval) diff --git a/homeassistant/components/starline/account.py b/homeassistant/components/starline/account.py new file mode 100644 index 00000000000..2e7653eb380 --- /dev/null +++ b/homeassistant/components/starline/account.py @@ -0,0 +1,142 @@ +"""StarLine Account.""" +from datetime import timedelta, datetime +from typing import Callable, Optional, Dict, Any +from starline import StarlineApi, StarlineDevice + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.event import async_track_time_interval + +from .const import ( + DOMAIN, + LOGGER, + DEFAULT_SCAN_INTERVAL, + DATA_USER_ID, + DATA_SLNET_TOKEN, + DATA_SLID_TOKEN, + DATA_EXPIRES, +) + + +class StarlineAccount: + """StarLine Account class.""" + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry): + """Constructor.""" + self._hass: HomeAssistant = hass + self._config_entry: ConfigEntry = config_entry + self._update_interval: int = DEFAULT_SCAN_INTERVAL + self._unsubscribe_auto_updater: Optional[Callable] = None + self._api: StarlineApi = StarlineApi( + config_entry.data[DATA_USER_ID], config_entry.data[DATA_SLNET_TOKEN] + ) + + def _check_slnet_token(self) -> None: + """Check SLNet token expiration and update if needed.""" + now = datetime.now().timestamp() + slnet_token_expires = self._config_entry.data[DATA_EXPIRES] + + if now + self._update_interval > slnet_token_expires: + self._update_slnet_token() + + def _update_slnet_token(self) -> None: + """Update SLNet token.""" + slid_token = self._config_entry.data[DATA_SLID_TOKEN] + + try: + slnet_token, slnet_token_expires, user_id = self._api.get_user_id( + slid_token + ) + self._api.set_slnet_token(slnet_token) + self._api.set_user_id(user_id) + self._hass.config_entries.async_update_entry( + self._config_entry, + data={ + **self._config_entry.data, + DATA_SLNET_TOKEN: slnet_token, + DATA_EXPIRES: slnet_token_expires, + DATA_USER_ID: user_id, + }, + ) + except Exception as err: # pylint: disable=broad-except + LOGGER.error("Error updating SLNet token: %s", err) + pass + + def _update_data(self): + """Update StarLine data.""" + self._check_slnet_token() + self._api.update() + + @property + def api(self) -> StarlineApi: + """Return the instance of the API.""" + return self._api + + async def update(self, unused=None): + """Update StarLine data.""" + await self._hass.async_add_executor_job(self._update_data) + + def set_update_interval(self, interval: int) -> None: + """Set StarLine API update interval.""" + LOGGER.debug("Setting update interval: %ds", interval) + self._update_interval = interval + if self._unsubscribe_auto_updater is not None: + self._unsubscribe_auto_updater() + + delta = timedelta(seconds=interval) + self._unsubscribe_auto_updater = async_track_time_interval( + self._hass, self.update, delta + ) + + def unload(self): + """Unload StarLine API.""" + LOGGER.debug("Unloading StarLine API.") + if self._unsubscribe_auto_updater is not None: + self._unsubscribe_auto_updater() + self._unsubscribe_auto_updater = None + + @staticmethod + def device_info(device: StarlineDevice) -> Dict[str, Any]: + """Device information for entities.""" + return { + "identifiers": {(DOMAIN, device.device_id)}, + "manufacturer": "StarLine", + "name": device.name, + "sw_version": device.fw_version, + "model": device.typename, + } + + @staticmethod + def gps_attrs(device: StarlineDevice) -> Dict[str, Any]: + """Attributes for device tracker.""" + return { + "updated": datetime.utcfromtimestamp(device.position["ts"]).isoformat(), + "online": device.online, + } + + @staticmethod + def balance_attrs(device: StarlineDevice) -> Dict[str, Any]: + """Attributes for balance sensor.""" + return { + "operator": device.balance.get("operator"), + "state": device.balance.get("state"), + "updated": device.balance.get("ts"), + } + + @staticmethod + def gsm_attrs(device: StarlineDevice) -> Dict[str, Any]: + """Attributes for GSM sensor.""" + return { + "raw": device.gsm_level, + "imei": device.imei, + "phone": device.phone, + "online": device.online, + } + + @staticmethod + def engine_attrs(device: StarlineDevice) -> Dict[str, Any]: + """Attributes for engine switch.""" + return { + "autostart": device.car_state.get("r_start"), + "ignition": device.car_state.get("run"), + } diff --git a/homeassistant/components/starline/binary_sensor.py b/homeassistant/components/starline/binary_sensor.py new file mode 100644 index 00000000000..fd28ff74cf4 --- /dev/null +++ b/homeassistant/components/starline/binary_sensor.py @@ -0,0 +1,58 @@ +"""Reads vehicle status from StarLine API.""" +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, + DEVICE_CLASS_DOOR, + DEVICE_CLASS_LOCK, + DEVICE_CLASS_PROBLEM, + DEVICE_CLASS_POWER, +) +from .account import StarlineAccount, StarlineDevice +from .const import DOMAIN +from .entity import StarlineEntity + +SENSOR_TYPES = { + "hbrake": ["Hand Brake", DEVICE_CLASS_POWER], + "hood": ["Hood", DEVICE_CLASS_DOOR], + "trunk": ["Trunk", DEVICE_CLASS_DOOR], + "alarm": ["Alarm", DEVICE_CLASS_PROBLEM], + "door": ["Doors", DEVICE_CLASS_LOCK], +} + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the StarLine sensors.""" + account: StarlineAccount = hass.data[DOMAIN][entry.entry_id] + entities = [] + for device in account.api.devices.values(): + for key, value in SENSOR_TYPES.items(): + if key in device.car_state: + sensor = StarlineSensor(account, device, key, *value) + if sensor.is_on is not None: + entities.append(sensor) + async_add_entities(entities) + + +class StarlineSensor(StarlineEntity, BinarySensorDevice): + """Representation of a StarLine binary sensor.""" + + def __init__( + self, + account: StarlineAccount, + device: StarlineDevice, + key: str, + name: str, + device_class: str, + ): + """Constructor.""" + super().__init__(account, device, key, name) + self._device_class = device_class + + @property + def device_class(self): + """Return the class of the binary sensor.""" + return self._device_class + + @property + def is_on(self): + """Return the state of the binary sensor.""" + return self._device.car_state.get(self._key) diff --git a/homeassistant/components/starline/config_flow.py b/homeassistant/components/starline/config_flow.py new file mode 100644 index 00000000000..2253cc3cd22 --- /dev/null +++ b/homeassistant/components/starline/config_flow.py @@ -0,0 +1,229 @@ +"""Config flow to configure StarLine component.""" +from typing import Optional +from starline import StarlineAuth +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD + +from .const import ( # pylint: disable=unused-import + DOMAIN, + CONF_APP_ID, + CONF_APP_SECRET, + CONF_MFA_CODE, + CONF_CAPTCHA_CODE, + LOGGER, + ERROR_AUTH_APP, + ERROR_AUTH_USER, + ERROR_AUTH_MFA, + DATA_USER_ID, + DATA_SLNET_TOKEN, + DATA_SLID_TOKEN, + DATA_EXPIRES, +) + + +class StarlineFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a StarLine config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize flow.""" + self._app_id: Optional[str] = None + self._app_secret: Optional[str] = None + self._username: Optional[str] = None + self._password: Optional[str] = None + self._mfa_code: Optional[str] = None + + self._app_code = None + self._app_token = None + self._user_slid = None + self._user_id = None + self._slnet_token = None + self._slnet_token_expires = None + self._captcha_image = None + self._captcha_sid = None + self._captcha_code = None + self._phone_number = None + + self._auth = StarlineAuth() + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + return await self.async_step_auth_app(user_input) + + async def async_step_auth_app(self, user_input=None, error=None): + """Authenticate application step.""" + if user_input is not None: + self._app_id = user_input[CONF_APP_ID] + self._app_secret = user_input[CONF_APP_SECRET] + return await self._async_authenticate_app(error) + return self._async_form_auth_app(error) + + async def async_step_auth_user(self, user_input=None, error=None): + """Authenticate user step.""" + if user_input is not None: + self._username = user_input[CONF_USERNAME] + self._password = user_input[CONF_PASSWORD] + return await self._async_authenticate_user(error) + return self._async_form_auth_user(error) + + async def async_step_auth_mfa(self, user_input=None, error=None): + """Authenticate mfa step.""" + if user_input is not None: + self._mfa_code = user_input[CONF_MFA_CODE] + return await self._async_authenticate_user(error) + return self._async_form_auth_mfa(error) + + async def async_step_auth_captcha(self, user_input=None, error=None): + """Captcha verification step.""" + if user_input is not None: + self._captcha_code = user_input[CONF_CAPTCHA_CODE] + return await self._async_authenticate_user(error) + return self._async_form_auth_captcha(error) + + def _async_form_auth_app(self, error=None): + """Authenticate application form.""" + errors = {} + if error is not None: + errors["base"] = error + + return self.async_show_form( + step_id="auth_app", + data_schema=vol.Schema( + { + vol.Required( + CONF_APP_ID, default=self._app_id or vol.UNDEFINED + ): str, + vol.Required( + CONF_APP_SECRET, default=self._app_secret or vol.UNDEFINED + ): str, + } + ), + errors=errors, + ) + + def _async_form_auth_user(self, error=None): + """Authenticate user form.""" + errors = {} + if error is not None: + errors["base"] = error + + return self.async_show_form( + step_id="auth_user", + data_schema=vol.Schema( + { + vol.Required( + CONF_USERNAME, default=self._username or vol.UNDEFINED + ): str, + vol.Required( + CONF_PASSWORD, default=self._password or vol.UNDEFINED + ): str, + } + ), + errors=errors, + ) + + def _async_form_auth_mfa(self, error=None): + """Authenticate mfa form.""" + errors = {} + if error is not None: + errors["base"] = error + + return self.async_show_form( + step_id="auth_mfa", + data_schema=vol.Schema( + { + vol.Required( + CONF_MFA_CODE, default=self._mfa_code or vol.UNDEFINED + ): str + } + ), + errors=errors, + description_placeholders={"phone_number": self._phone_number}, + ) + + def _async_form_auth_captcha(self, error=None): + """Captcha verification form.""" + errors = {} + if error is not None: + errors["base"] = error + + return self.async_show_form( + step_id="auth_captcha", + data_schema=vol.Schema( + { + vol.Required( + CONF_CAPTCHA_CODE, default=self._captcha_code or vol.UNDEFINED + ): str + } + ), + errors=errors, + description_placeholders={ + "captcha_img": '' + }, + ) + + async def _async_authenticate_app(self, error=None): + """Authenticate application.""" + try: + self._app_code = self._auth.get_app_code(self._app_id, self._app_secret) + self._app_token = self._auth.get_app_token( + self._app_id, self._app_secret, self._app_code + ) + return self._async_form_auth_user(error) + except Exception as err: # pylint: disable=broad-except + LOGGER.error("Error auth StarLine: %s", err) + return self._async_form_auth_app(ERROR_AUTH_APP) + + async def _async_authenticate_user(self, error=None): + """Authenticate user.""" + try: + state, data = self._auth.get_slid_user_token( + self._app_token, + self._username, + self._password, + self._mfa_code, + self._captcha_sid, + self._captcha_code, + ) + + if state == 1: + self._user_slid = data["user_token"] + return await self._async_get_entry() + + if "phone" in data: + self._phone_number = data["phone"] + if state == 0: + error = ERROR_AUTH_MFA + return self._async_form_auth_mfa(error) + + if "captchaSid" in data: + self._captcha_sid = data["captchaSid"] + self._captcha_image = data["captchaImg"] + return self._async_form_auth_captcha(error) + + raise Exception(data) + except Exception as err: # pylint: disable=broad-except + LOGGER.error("Error auth user: %s", err) + return self._async_form_auth_user(ERROR_AUTH_USER) + + async def _async_get_entry(self): + """Create entry.""" + ( + self._slnet_token, + self._slnet_token_expires, + self._user_id, + ) = self._auth.get_user_id(self._user_slid) + + return self.async_create_entry( + title=f"Application {self._app_id}", + data={ + DATA_USER_ID: self._user_id, + DATA_SLNET_TOKEN: self._slnet_token, + DATA_SLID_TOKEN: self._user_slid, + DATA_EXPIRES: self._slnet_token_expires, + }, + ) diff --git a/homeassistant/components/starline/const.py b/homeassistant/components/starline/const.py new file mode 100644 index 00000000000..d76cd47b100 --- /dev/null +++ b/homeassistant/components/starline/const.py @@ -0,0 +1,27 @@ +"""StarLine constants.""" +import logging + +LOGGER = logging.getLogger(__package__) + +DOMAIN = "starline" +PLATFORMS = ["device_tracker", "binary_sensor", "sensor", "lock", "switch"] + +CONF_APP_ID = "app_id" +CONF_APP_SECRET = "app_secret" +CONF_MFA_CODE = "mfa_code" +CONF_CAPTCHA_CODE = "captcha_code" + +CONF_SCAN_INTERVAL = "scan_interval" +DEFAULT_SCAN_INTERVAL = 180 # in seconds + +ERROR_AUTH_APP = "error_auth_app" +ERROR_AUTH_USER = "error_auth_user" +ERROR_AUTH_MFA = "error_auth_mfa" + +DATA_USER_ID = "user_id" +DATA_SLNET_TOKEN = "slnet_token" +DATA_SLID_TOKEN = "slid_token" +DATA_EXPIRES = "expires" + +SERVICE_UPDATE_STATE = "update_state" +SERVICE_SET_SCAN_INTERVAL = "set_scan_interval" diff --git a/homeassistant/components/starline/device_tracker.py b/homeassistant/components/starline/device_tracker.py new file mode 100644 index 00000000000..b5254c761d8 --- /dev/null +++ b/homeassistant/components/starline/device_tracker.py @@ -0,0 +1,60 @@ +"""StarLine device tracker.""" +from homeassistant.components.device_tracker.config_entry import TrackerEntity +from homeassistant.components.device_tracker.const import SOURCE_TYPE_GPS +from homeassistant.helpers.restore_state import RestoreEntity +from .account import StarlineAccount, StarlineDevice +from .const import DOMAIN +from .entity import StarlineEntity + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up StarLine entry.""" + account: StarlineAccount = hass.data[DOMAIN][entry.entry_id] + entities = [] + for device in account.api.devices.values(): + if device.support_position: + entities.append(StarlineDeviceTracker(account, device)) + async_add_entities(entities) + + +class StarlineDeviceTracker(StarlineEntity, TrackerEntity, RestoreEntity): + """StarLine device tracker.""" + + def __init__(self, account: StarlineAccount, device: StarlineDevice): + """Set up StarLine entity.""" + super().__init__(account, device, "location", "Location") + + @property + def device_state_attributes(self): + """Return device specific attributes.""" + return self._account.gps_attrs(self._device) + + @property + def battery_level(self): + """Return the battery level of the device.""" + return self._device.battery_level + + @property + def location_accuracy(self): + """Return the gps accuracy of the device.""" + return self._device.position["r"] if "r" in self._device.position else 0 + + @property + def latitude(self): + """Return latitude value of the device.""" + return self._device.position["x"] + + @property + def longitude(self): + """Return longitude value of the device.""" + return self._device.position["y"] + + @property + def source_type(self): + """Return the source type, eg gps or router, of the device.""" + return SOURCE_TYPE_GPS + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return "mdi:map-marker-outline" diff --git a/homeassistant/components/starline/entity.py b/homeassistant/components/starline/entity.py new file mode 100644 index 00000000000..b0d948ae2c8 --- /dev/null +++ b/homeassistant/components/starline/entity.py @@ -0,0 +1,59 @@ +"""StarLine base entity.""" +from typing import Callable, Optional +from homeassistant.helpers.entity import Entity +from .account import StarlineAccount, StarlineDevice + + +class StarlineEntity(Entity): + """StarLine base entity class.""" + + def __init__( + self, account: StarlineAccount, device: StarlineDevice, key: str, name: str + ): + """Constructor.""" + self._account = account + self._device = device + self._key = key + self._name = name + self._unsubscribe_api: Optional[Callable] = None + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def available(self): + """Return True if entity is available.""" + return self._account.api.available + + @property + def unique_id(self): + """Return the unique ID of the entity.""" + return f"starline-{self._key}-{self._device.device_id}" + + @property + def name(self): + """Return the name of the entity.""" + return f"{self._device.name} {self._name}" + + @property + def device_info(self): + """Return the device info.""" + return self._account.device_info(self._device) + + def update(self): + """Read new state data.""" + self.schedule_update_ha_state() + + async def async_added_to_hass(self): + """Call when entity about to be added to Home Assistant.""" + await super().async_added_to_hass() + self._unsubscribe_api = self._account.api.add_update_listener(self.update) + + async def async_will_remove_from_hass(self): + """Call when entity is being removed from Home Assistant.""" + await super().async_will_remove_from_hass() + if self._unsubscribe_api is not None: + self._unsubscribe_api() + self._unsubscribe_api = None diff --git a/homeassistant/components/starline/lock.py b/homeassistant/components/starline/lock.py new file mode 100644 index 00000000000..0a20a36ae8b --- /dev/null +++ b/homeassistant/components/starline/lock.py @@ -0,0 +1,72 @@ +"""Support for StarLine lock.""" +from homeassistant.components.lock import LockDevice +from .account import StarlineAccount, StarlineDevice +from .const import DOMAIN +from .entity import StarlineEntity + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the StarLine lock.""" + + account: StarlineAccount = hass.data[DOMAIN][entry.entry_id] + entities = [] + for device in account.api.devices.values(): + if device.support_state: + lock = StarlineLock(account, device) + if lock.is_locked is not None: + entities.append(lock) + async_add_entities(entities) + + +class StarlineLock(StarlineEntity, LockDevice): + """Representation of a StarLine lock.""" + + def __init__(self, account: StarlineAccount, device: StarlineDevice): + """Initialize the lock.""" + super().__init__(account, device, "lock", "Security") + + @property + def available(self): + """Return True if entity is available.""" + return super().available and self._device.online + + @property + def device_state_attributes(self): + """Return the state attributes of the lock. + + Possible dictionary keys: + add_h - Additional sensor alarm status (high level) + add_l - Additional channel alarm status (low level) + door - Doors alarm status + hbrake - Hand brake alarm status + hijack - Hijack mode status + hood - Hood alarm status + ign - Ignition alarm status + pbrake - Brake pedal alarm status + shock_h - Shock sensor alarm status (high level) + shock_l - Shock sensor alarm status (low level) + tilt - Tilt sensor alarm status + trunk - Trunk alarm status + Documentation: https://developer.starline.ru/#api-Device-DeviceState + """ + return self._device.alarm_state + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return ( + "mdi:shield-check-outline" if self.is_locked else "mdi:shield-alert-outline" + ) + + @property + def is_locked(self): + """Return true if lock is locked.""" + return self._device.car_state.get("arm") + + def lock(self, **kwargs): + """Lock the car.""" + self._account.api.set_car_state(self._device.device_id, "arm", True) + + def unlock(self, **kwargs): + """Unlock the car.""" + self._account.api.set_car_state(self._device.device_id, "arm", False) diff --git a/homeassistant/components/starline/manifest.json b/homeassistant/components/starline/manifest.json new file mode 100644 index 00000000000..ef343aae4ce --- /dev/null +++ b/homeassistant/components/starline/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "starline", + "name": "StarLine", + "config_flow": true, + "documentation": "https://www.home-assistant.io/components/starline", + "requirements": [ + "starline==0.1.3" + ], + "dependencies": [], + "codeowners": [ + "@anonym-tsk" + ] +} diff --git a/homeassistant/components/starline/sensor.py b/homeassistant/components/starline/sensor.py new file mode 100644 index 00000000000..2507aba4955 --- /dev/null +++ b/homeassistant/components/starline/sensor.py @@ -0,0 +1,95 @@ +"""Reads vehicle status from StarLine API.""" +from homeassistant.components.sensor import DEVICE_CLASS_TEMPERATURE +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.icon import icon_for_battery_level, icon_for_signal_level +from .account import StarlineAccount, StarlineDevice +from .const import DOMAIN +from .entity import StarlineEntity + +SENSOR_TYPES = { + "battery": ["Battery", None, "V", None], + "balance": ["Balance", None, None, "mdi:cash-multiple"], + "ctemp": ["Interior Temperature", DEVICE_CLASS_TEMPERATURE, None, None], + "etemp": ["Engine Temperature", DEVICE_CLASS_TEMPERATURE, None, None], + "gsm_lvl": ["GSM Signal", None, "%", None], +} + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the StarLine sensors.""" + account: StarlineAccount = hass.data[DOMAIN][entry.entry_id] + entities = [] + for device in account.api.devices.values(): + for key, value in SENSOR_TYPES.items(): + sensor = StarlineSensor(account, device, key, *value) + if sensor.state is not None: + entities.append(sensor) + async_add_entities(entities) + + +class StarlineSensor(StarlineEntity, Entity): + """Representation of a StarLine sensor.""" + + def __init__( + self, + account: StarlineAccount, + device: StarlineDevice, + key: str, + name: str, + device_class: str, + unit: str, + icon: str, + ): + """Constructor.""" + super().__init__(account, device, key, name) + self._device_class = device_class + self._unit = unit + self._icon = icon + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + if self._key == "battery": + return icon_for_battery_level( + battery_level=self._device.battery_level_percent, + charging=self._device.car_state.get("ign", False), + ) + if self._key == "gsm_lvl": + return icon_for_signal_level(signal_level=self._device.gsm_level_percent) + return self._icon + + @property + def state(self): + """Return the state of the sensor.""" + if self._key == "battery": + return self._device.battery_level + if self._key == "balance": + return self._device.balance.get("value") + if self._key == "ctemp": + return self._device.temp_inner + if self._key == "etemp": + return self._device.temp_engine + if self._key == "gsm_lvl": + return self._device.gsm_level_percent + return None + + @property + def unit_of_measurement(self): + """Get the unit of measurement.""" + if self._key == "balance": + return self._device.balance.get("currency") or "₽" + return self._unit + + @property + def device_class(self): + """Return the class of the sensor.""" + return self._device_class + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + if self._key == "balance": + return self._account.balance_attrs(self._device) + if self._key == "gsm_lvl": + return self._account.gsm_attrs(self._device) + return None diff --git a/homeassistant/components/starline/services.yaml b/homeassistant/components/starline/services.yaml new file mode 100644 index 00000000000..bef3a16803e --- /dev/null +++ b/homeassistant/components/starline/services.yaml @@ -0,0 +1,10 @@ +update_state: + description: > + Fetch the last state of the devices from the StarLine server. +set_scan_interval: + description: > + Set update frequency. + fields: + scan_interval: + description: Update frequency (in seconds). + example: 180 diff --git a/homeassistant/components/starline/strings.json b/homeassistant/components/starline/strings.json new file mode 100644 index 00000000000..bf83f652c3c --- /dev/null +++ b/homeassistant/components/starline/strings.json @@ -0,0 +1,42 @@ +{ + "config": { + "title": "StarLine", + "step": { + "auth_app": { + "title": "Application credentials", + "description": "Application ID and secret code from StarLine developer account", + "data": { + "app_id": "App ID", + "app_secret": "Secret" + } + }, + "auth_user": { + "title": "User credentials", + "description": "StarLine account email and password", + "data": { + "username": "Username", + "password": "Password" + } + }, + "auth_mfa": { + "title": "Two-factor authorization", + "description": "Enter the code sent to phone {phone_number}", + "data": { + "mfa_code": "SMS code" + } + }, + "auth_captcha": { + "title": "Captcha", + "description": "{captcha_img}", + "data": { + "captcha_code": "Code from image" + } + } + }, + "error": { + "error_auth_app": "Incorrect application id or secret", + "error_auth_user": "Incorrect username or password", + "error_auth_mfa": "Incorrect code" + } + } +} diff --git a/homeassistant/components/starline/switch.py b/homeassistant/components/starline/switch.py new file mode 100644 index 00000000000..92dec10b9d3 --- /dev/null +++ b/homeassistant/components/starline/switch.py @@ -0,0 +1,86 @@ +"""Support for StarLine switch.""" +from homeassistant.components.switch import SwitchDevice +from .account import StarlineAccount, StarlineDevice +from .const import DOMAIN +from .entity import StarlineEntity + +SWITCH_TYPES = { + "ign": ["Engine", "mdi:engine-outline", "mdi:engine-off-outline"], + "webasto": ["Webasto", "mdi:radiator", "mdi:radiator-off"], + "out": [ + "Additional Channel", + "mdi:access-point-network", + "mdi:access-point-network-off", + ], + "poke": ["Horn", "mdi:bullhorn-outline", "mdi:bullhorn-outline"], +} + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the StarLine switch.""" + account: StarlineAccount = hass.data[DOMAIN][entry.entry_id] + entities = [] + for device in account.api.devices.values(): + if device.support_state: + for key, value in SWITCH_TYPES.items(): + switch = StarlineSwitch(account, device, key, *value) + if switch.is_on is not None: + entities.append(switch) + async_add_entities(entities) + + +class StarlineSwitch(StarlineEntity, SwitchDevice): + """Representation of a StarLine switch.""" + + def __init__( + self, + account: StarlineAccount, + device: StarlineDevice, + key: str, + name: str, + icon_on: str, + icon_off: str, + ): + """Initialize the switch.""" + super().__init__(account, device, key, name) + self._icon_on = icon_on + self._icon_off = icon_off + + @property + def available(self): + """Return True if entity is available.""" + return super().available and self._device.online + + @property + def device_state_attributes(self): + """Return the state attributes of the switch.""" + if self._key == "ign": + return self._account.engine_attrs(self._device) + return None + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self._icon_on if self.is_on else self._icon_off + + @property + def assumed_state(self): + """Return True if unable to access real state of the entity.""" + return True + + @property + def is_on(self): + """Return True if entity is on.""" + if self._key == "poke": + return False + return self._device.car_state.get(self._key) + + def turn_on(self, **kwargs): + """Turn the entity on.""" + self._account.api.set_car_state(self._device.device_id, self._key, True) + + def turn_off(self, **kwargs) -> None: + """Turn the entity off.""" + if self._key == "poke": + return + self._account.api.set_car_state(self._device.device_id, self._key, False) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index c496fc99cf1..8d4be47f5f8 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -69,6 +69,7 @@ FLOWS = [ "soma", "somfy", "sonos", + "starline", "tellduslive", "toon", "tplink", diff --git a/homeassistant/helpers/icon.py b/homeassistant/helpers/icon.py index 96c3b7e08c1..b2a1d58717b 100644 --- a/homeassistant/helpers/icon.py +++ b/homeassistant/helpers/icon.py @@ -18,3 +18,14 @@ def icon_for_battery_level( elif 5 < battery_level < 95: icon += "-{}".format(int(round(battery_level / 10 - 0.01)) * 10) return icon + + +def icon_for_signal_level(signal_level: Optional[int] = None) -> str: + """Return a signal icon valid identifier.""" + if signal_level is None or signal_level == 0: + return "mdi:signal-cellular-outline" + if signal_level > 70: + return "mdi:signal-cellular-3" + if signal_level > 30: + return "mdi:signal-cellular-2" + return "mdi:signal-cellular-1" diff --git a/requirements_all.txt b/requirements_all.txt index 477cf1314de..987b231005d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1859,6 +1859,9 @@ spotipy-homeassistant==2.4.4.dev1 # homeassistant.components.sql sqlalchemy==1.3.11 +# homeassistant.components.starline +starline==0.1.3 + # homeassistant.components.starlingbank starlingbank==3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 06be8aac043..56a48905eb5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -574,6 +574,9 @@ somecomfort==0.5.2 # homeassistant.components.sql sqlalchemy==1.3.11 +# homeassistant.components.starline +starline==0.1.3 + # homeassistant.components.statsd statsd==3.2.1 diff --git a/tests/components/starline/__init__.py b/tests/components/starline/__init__.py new file mode 100644 index 00000000000..58f50c0f1b9 --- /dev/null +++ b/tests/components/starline/__init__.py @@ -0,0 +1 @@ +"""Tests for the StarLine component.""" diff --git a/tests/components/starline/test_config_flow.py b/tests/components/starline/test_config_flow.py new file mode 100644 index 00000000000..31bdf98b404 --- /dev/null +++ b/tests/components/starline/test_config_flow.py @@ -0,0 +1,126 @@ +"""Tests for StarLine config flow.""" +import requests_mock +from homeassistant.components.starline import config_flow + +TEST_APP_ID = "666" +TEST_APP_SECRET = "appsecret" +TEST_APP_CODE = "appcode" +TEST_APP_TOKEN = "apptoken" +TEST_APP_SLNET = "slnettoken" +TEST_APP_SLID = "slidtoken" +TEST_APP_UID = "123" +TEST_APP_USERNAME = "sluser" +TEST_APP_PASSWORD = "slpassword" + + +async def test_flow_works(hass): + """Test that config flow works.""" + with requests_mock.Mocker() as mock: + mock.get( + "https://id.starline.ru/apiV3/application/getCode/", + text='{"state": 1, "desc": {"code": "' + TEST_APP_CODE + '"}}', + ) + mock.get( + "https://id.starline.ru/apiV3/application/getToken/", + text='{"state": 1, "desc": {"token": "' + TEST_APP_TOKEN + '"}}', + ) + mock.post( + "https://id.starline.ru/apiV3/user/login/", + text='{"state": 1, "desc": {"user_token": "' + TEST_APP_SLID + '"}}', + ) + mock.post( + "https://developer.starline.ru/json/v2/auth.slid", + text='{"code": 200, "user_id": "' + TEST_APP_UID + '"}', + cookies={"slnet": TEST_APP_SLNET}, + ) + mock.get( + "https://developer.starline.ru/json/v2/user/{}/user_info".format( + TEST_APP_UID + ), + text='{"code": 200, "devices": [{"device_id": "123", "imei": "123", "alias": "123", "battery": "123", "ctemp": "123", "etemp": "123", "fw_version": "123", "gsm_lvl": "123", "phone": "123", "status": "1", "ts_activity": "123", "typename": "123", "balance": {}, "car_state": {}, "car_alr_state": {}, "functions": [], "position": {}}], "shared_devices": []}', + ) + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": "user"} + ) + assert result["type"] == "form" + assert result["step_id"] == "auth_app" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + config_flow.CONF_APP_ID: TEST_APP_ID, + config_flow.CONF_APP_SECRET: TEST_APP_SECRET, + }, + ) + assert result["type"] == "form" + assert result["step_id"] == "auth_user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + config_flow.CONF_USERNAME: TEST_APP_USERNAME, + config_flow.CONF_PASSWORD: TEST_APP_PASSWORD, + }, + ) + assert result["type"] == "create_entry" + assert result["title"] == "Application {}".format(TEST_APP_ID) + + +async def test_step_auth_app_code_falls(hass): + """Test config flow works when app auth code fails.""" + with requests_mock.Mocker() as mock: + mock.get( + "https://id.starline.ru/apiV3/application/getCode/", text='{"state": 0}}' + ) + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": "user"}, + data={ + config_flow.CONF_APP_ID: TEST_APP_ID, + config_flow.CONF_APP_SECRET: TEST_APP_SECRET, + }, + ) + assert result["type"] == "form" + assert result["step_id"] == "auth_app" + assert result["errors"] == {"base": "error_auth_app"} + + +async def test_step_auth_app_token_falls(hass): + """Test config flow works when app auth token fails.""" + with requests_mock.Mocker() as mock: + mock.get( + "https://id.starline.ru/apiV3/application/getCode/", + text='{"state": 1, "desc": {"code": "' + TEST_APP_CODE + '"}}', + ) + mock.get( + "https://id.starline.ru/apiV3/application/getToken/", text='{"state": 0}' + ) + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": "user"}, + data={ + config_flow.CONF_APP_ID: TEST_APP_ID, + config_flow.CONF_APP_SECRET: TEST_APP_SECRET, + }, + ) + assert result["type"] == "form" + assert result["step_id"] == "auth_app" + assert result["errors"] == {"base": "error_auth_app"} + + +async def test_step_auth_user_falls(hass): + """Test config flow works when user fails.""" + with requests_mock.Mocker() as mock: + mock.post("https://id.starline.ru/apiV3/user/login/", text='{"state": 0}') + flow = config_flow.StarlineFlowHandler() + flow.hass = hass + result = await flow.async_step_auth_user( + user_input={ + config_flow.CONF_USERNAME: TEST_APP_USERNAME, + config_flow.CONF_PASSWORD: TEST_APP_PASSWORD, + } + ) + assert result["type"] == "form" + assert result["step_id"] == "auth_user" + assert result["errors"] == {"base": "error_auth_user"} diff --git a/tests/helpers/test_icon.py b/tests/helpers/test_icon.py index ce6e95110c9..4f1d4cb223f 100644 --- a/tests/helpers/test_icon.py +++ b/tests/helpers/test_icon.py @@ -44,3 +44,15 @@ def test_battery_icon(): postfix = "" assert iconbase + postfix == icon_for_battery_level(level, False) assert iconbase + postfix_charging == icon_for_battery_level(level, True) + + +def test_signal_icon(): + """Test icon generator for signal sensor.""" + from homeassistant.helpers.icon import icon_for_signal_level + + assert icon_for_signal_level(None) == "mdi:signal-cellular-outline" + assert icon_for_signal_level(0) == "mdi:signal-cellular-outline" + assert icon_for_signal_level(5) == "mdi:signal-cellular-1" + assert icon_for_signal_level(40) == "mdi:signal-cellular-2" + assert icon_for_signal_level(80) == "mdi:signal-cellular-3" + assert icon_for_signal_level(100) == "mdi:signal-cellular-3"