StarLine integration (#27197)

* Device Tracker works

* Device Tracker works

* Binary Sensor

* Sensor

* Lock

* Switch and service

* New switches

* Update interval options

* WIP

* Translation errors

* Check online state

* WIP

* Move to aiohttp

* Some checks

* CI

* CI

* .coveragerc

* Black

* icon_for_signal_level test

* update_interval renamed to scan_interval

* async logic

* Fix cookie read

* Requirement starline

* Reformat

* Requirements updated

* ConfigEntryNotReady

* Requirement starline

* Lint fix

* Requirement starline

* available status

* Translations

* Expiration to config

* CI

* Linter fix

* Small renaming

* Update slnet token

* Starline version bump

* Fix updates

* Black

* Small fix

* Removed unused fields

* CI

* set_scan_interval service

* deps updated

* Horn switch

* Starline lib updated

* Starline lib updated

* Black

* Support multiple integrations

* Review

* async_will_remove_from_hass

* Deps updated

* Test config flow

* Requirements

* CI

* Review

* Review

* Review

* Review

* Review

* CI

* pylint fix

* Review

* Support "mayak" devices

* Icons removed

* Removed options_flow

* Removed options_flow test

* Removed options_flow test
This commit is contained in:
Nikolay Vasilchuk 2019-11-26 22:17:11 +03:00 committed by Paulus Schoutsen
parent c21650473a
commit a37260faa9
23 changed files with 1179 additions and 0 deletions

View File

@ -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/*

View File

@ -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

View File

@ -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 <a href=\"https://my.starline.ru/developer\" target=\"_blank\">StarLine developer account</a>",
"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"
}
}

View File

@ -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)

View File

@ -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"),
}

View File

@ -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)

View File

@ -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": '<img src="' + self._captcha_image + '"/>'
},
)
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,
},
)

View File

@ -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"

View File

@ -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"

View File

@ -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

View File

@ -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)

View File

@ -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"
]
}

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,42 @@
{
"config": {
"title": "StarLine",
"step": {
"auth_app": {
"title": "Application credentials",
"description": "Application ID and secret code from <a href=\"https://my.starline.ru/developer\" target=\"_blank\">StarLine developer account</a>",
"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"
}
}
}

View File

@ -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)

View File

@ -69,6 +69,7 @@ FLOWS = [
"soma",
"somfy",
"sonos",
"starline",
"tellduslive",
"toon",
"tplink",

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
"""Tests for the StarLine component."""

View File

@ -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"}

View File

@ -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"