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"