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 testpull/29114/head
parent
c21650473a
commit
a37260faa9
|
@ -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/*
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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)
|
|
@ -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"),
|
||||
}
|
|
@ -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)
|
|
@ -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,
|
||||
},
|
||||
)
|
|
@ -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"
|
|
@ -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"
|
|
@ -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
|
|
@ -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)
|
|
@ -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"
|
||||
]
|
||||
}
|
|
@ -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
|
|
@ -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
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
|
@ -69,6 +69,7 @@ FLOWS = [
|
|||
"soma",
|
||||
"somfy",
|
||||
"sonos",
|
||||
"starline",
|
||||
"tellduslive",
|
||||
"toon",
|
||||
"tplink",
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
"""Tests for the StarLine component."""
|
|
@ -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"}
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue