mirror of
https://github.com/home-assistant/core.git
synced 2025-07-30 18:58:04 +02:00
Add LOOKin integration (#58125)
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
@ -585,6 +585,10 @@ omit =
|
|||||||
homeassistant/components/logi_circle/const.py
|
homeassistant/components/logi_circle/const.py
|
||||||
homeassistant/components/logi_circle/sensor.py
|
homeassistant/components/logi_circle/sensor.py
|
||||||
homeassistant/components/london_underground/sensor.py
|
homeassistant/components/london_underground/sensor.py
|
||||||
|
homeassistant/components/lookin/__init__.py
|
||||||
|
homeassistant/components/lookin/entity.py
|
||||||
|
homeassistant/components/lookin/models.py
|
||||||
|
homeassistant/components/lookin/sensor.py
|
||||||
homeassistant/components/loopenergy/sensor.py
|
homeassistant/components/loopenergy/sensor.py
|
||||||
homeassistant/components/luci/device_tracker.py
|
homeassistant/components/luci/device_tracker.py
|
||||||
homeassistant/components/luftdaten/__init__.py
|
homeassistant/components/luftdaten/__init__.py
|
||||||
|
@ -287,6 +287,7 @@ homeassistant/components/litterrobot/* @natekspencer
|
|||||||
homeassistant/components/local_ip/* @issacg
|
homeassistant/components/local_ip/* @issacg
|
||||||
homeassistant/components/logger/* @home-assistant/core
|
homeassistant/components/logger/* @home-assistant/core
|
||||||
homeassistant/components/logi_circle/* @evanjd
|
homeassistant/components/logi_circle/* @evanjd
|
||||||
|
homeassistant/components/lookin/* @ANMalko
|
||||||
homeassistant/components/loopenergy/* @pavoni
|
homeassistant/components/loopenergy/* @pavoni
|
||||||
homeassistant/components/lovelace/* @home-assistant/frontend
|
homeassistant/components/lovelace/* @home-assistant/frontend
|
||||||
homeassistant/components/luci/* @mzdrale
|
homeassistant/components/luci/* @mzdrale
|
||||||
|
68
homeassistant/components/lookin/__init__.py
Normal file
68
homeassistant/components/lookin/__init__.py
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
"""The lookin integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from aiolookin import LookInHttpProtocol, LookinUDPSubscriptions, start_lookin_udp
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import CONF_HOST
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||||
|
|
||||||
|
from .const import DOMAIN, PLATFORMS
|
||||||
|
from .models import LookinData
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Set up lookin from a config entry."""
|
||||||
|
|
||||||
|
host = entry.data[CONF_HOST]
|
||||||
|
lookin_protocol = LookInHttpProtocol(
|
||||||
|
api_uri=f"http://{host}", session=async_get_clientsession(hass)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
lookin_device = await lookin_protocol.get_info()
|
||||||
|
devices = await lookin_protocol.get_devices()
|
||||||
|
except aiohttp.ClientError as ex:
|
||||||
|
raise ConfigEntryNotReady from ex
|
||||||
|
|
||||||
|
meteo_coordinator: DataUpdateCoordinator = DataUpdateCoordinator(
|
||||||
|
hass,
|
||||||
|
LOGGER,
|
||||||
|
name=entry.title,
|
||||||
|
update_method=lookin_protocol.get_meteo_sensor,
|
||||||
|
update_interval=timedelta(
|
||||||
|
minutes=5
|
||||||
|
), # Updates are pushed (fallback is polling)
|
||||||
|
)
|
||||||
|
await meteo_coordinator.async_config_entry_first_refresh()
|
||||||
|
|
||||||
|
lookin_udp_subs = LookinUDPSubscriptions()
|
||||||
|
entry.async_on_unload(await start_lookin_udp(lookin_udp_subs))
|
||||||
|
|
||||||
|
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = LookinData(
|
||||||
|
lookin_udp_subs=lookin_udp_subs,
|
||||||
|
lookin_device=lookin_device,
|
||||||
|
meteo_coordinator=meteo_coordinator,
|
||||||
|
devices=devices,
|
||||||
|
lookin_protocol=lookin_protocol,
|
||||||
|
)
|
||||||
|
|
||||||
|
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Unload a config entry."""
|
||||||
|
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||||
|
hass.data[DOMAIN].pop(entry.entry_id)
|
||||||
|
return unload_ok
|
108
homeassistant/components/lookin/config_flow.py
Normal file
108
homeassistant/components/lookin/config_flow.py
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
"""The lookin integration config_flow."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from aiolookin import Device, LookInHttpProtocol, NoUsableService
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.const import CONF_HOST
|
||||||
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
from homeassistant.helpers.typing import DiscoveryInfoType
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LookinFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Handle a config flow for lookin."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Init the lookin flow."""
|
||||||
|
self._host: str | None = None
|
||||||
|
self._name: str | None = None
|
||||||
|
|
||||||
|
async def async_step_zeroconf(
|
||||||
|
self, discovery_info: DiscoveryInfoType
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Start a discovery flow from zeroconf."""
|
||||||
|
uid: str = discovery_info["hostname"][: -len(".local.")]
|
||||||
|
host: str = discovery_info["host"]
|
||||||
|
await self.async_set_unique_id(uid.upper())
|
||||||
|
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
|
||||||
|
|
||||||
|
try:
|
||||||
|
device: Device = await self._validate_device(host=host)
|
||||||
|
except (aiohttp.ClientError, NoUsableService):
|
||||||
|
return self.async_abort(reason="cannot_connect")
|
||||||
|
except Exception: # pylint: disable=broad-except
|
||||||
|
LOGGER.exception("Unexpected exception")
|
||||||
|
return self.async_abort(reason="unknown")
|
||||||
|
else:
|
||||||
|
self._name = device.name
|
||||||
|
|
||||||
|
self._host = host
|
||||||
|
self._set_confirm_only()
|
||||||
|
self.context["title_placeholders"] = {"name": self._name, "host": host}
|
||||||
|
return await self.async_step_discovery_confirm()
|
||||||
|
|
||||||
|
async def async_step_user(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""User initiated discover flow."""
|
||||||
|
errors: dict[str, str] = {}
|
||||||
|
|
||||||
|
if user_input is not None:
|
||||||
|
host = user_input[CONF_HOST]
|
||||||
|
try:
|
||||||
|
device = await self._validate_device(host=host)
|
||||||
|
except (aiohttp.ClientError, NoUsableService):
|
||||||
|
errors[CONF_HOST] = "cannot_connect"
|
||||||
|
except Exception: # pylint: disable=broad-except
|
||||||
|
LOGGER.exception("Unexpected exception")
|
||||||
|
errors["base"] = "unknown"
|
||||||
|
else:
|
||||||
|
device_id = device.id.upper()
|
||||||
|
await self.async_set_unique_id(device_id, raise_on_progress=False)
|
||||||
|
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
|
||||||
|
return self.async_create_entry(
|
||||||
|
title=device.name or host,
|
||||||
|
data={CONF_HOST: host},
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user",
|
||||||
|
data_schema=vol.Schema({vol.Required(CONF_HOST): str}),
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _validate_device(self, host: str) -> Device:
|
||||||
|
"""Validate we can connect to the device."""
|
||||||
|
session = async_get_clientsession(self.hass)
|
||||||
|
lookin_protocol = LookInHttpProtocol(host, session)
|
||||||
|
return await lookin_protocol.get_info()
|
||||||
|
|
||||||
|
async def async_step_discovery_confirm(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Confirm the discover flow."""
|
||||||
|
assert self._host is not None
|
||||||
|
if user_input is None:
|
||||||
|
self.context["title_placeholders"] = {
|
||||||
|
"name": self._name,
|
||||||
|
"host": self._host,
|
||||||
|
}
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="discovery_confirm",
|
||||||
|
description_placeholders={"name": self._name, "host": self._host},
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.async_create_entry(
|
||||||
|
title=self._name or self._host,
|
||||||
|
data={CONF_HOST: self._host},
|
||||||
|
)
|
9
homeassistant/components/lookin/const.py
Normal file
9
homeassistant/components/lookin/const.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
"""The lookin integration constants."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
DOMAIN: Final = "lookin"
|
||||||
|
PLATFORMS: Final = [
|
||||||
|
"sensor",
|
||||||
|
]
|
84
homeassistant/components/lookin/entity.py
Normal file
84
homeassistant/components/lookin/entity.py
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
"""The lookin integration entity."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from aiolookin import POWER_CMD, POWER_OFF_CMD, POWER_ON_CMD, Climate, Remote
|
||||||
|
|
||||||
|
from homeassistant.helpers.entity import DeviceInfo, Entity
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .models import LookinData
|
||||||
|
|
||||||
|
|
||||||
|
class LookinDeviceEntity(Entity):
|
||||||
|
"""A lookin device entity on the device itself."""
|
||||||
|
|
||||||
|
_attr_should_poll = False
|
||||||
|
|
||||||
|
def __init__(self, lookin_data: LookinData) -> None:
|
||||||
|
"""Init the lookin device entity."""
|
||||||
|
super().__init__()
|
||||||
|
self._lookin_device = lookin_data.lookin_device
|
||||||
|
self._lookin_protocol = lookin_data.lookin_protocol
|
||||||
|
self._lookin_udp_subs = lookin_data.lookin_udp_subs
|
||||||
|
self._attr_device_info = DeviceInfo(
|
||||||
|
identifiers={(DOMAIN, self._lookin_device.id)},
|
||||||
|
name=self._lookin_device.name,
|
||||||
|
manufacturer="LOOKin",
|
||||||
|
model="LOOKin Remote2",
|
||||||
|
sw_version=self._lookin_device.firmware,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LookinEntity(Entity):
|
||||||
|
"""A base class for lookin entities."""
|
||||||
|
|
||||||
|
_attr_should_poll = False
|
||||||
|
_attr_assumed_state = True
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
uuid: str,
|
||||||
|
device: Remote | Climate,
|
||||||
|
lookin_data: LookinData,
|
||||||
|
) -> None:
|
||||||
|
"""Init the base entity."""
|
||||||
|
self._device = device
|
||||||
|
self._uuid = uuid
|
||||||
|
self._lookin_device = lookin_data.lookin_device
|
||||||
|
self._lookin_protocol = lookin_data.lookin_protocol
|
||||||
|
self._lookin_udp_subs = lookin_data.lookin_udp_subs
|
||||||
|
self._meteo_coordinator = lookin_data.meteo_coordinator
|
||||||
|
self._function_names = {function.name for function in self._device.functions}
|
||||||
|
self._attr_unique_id = uuid
|
||||||
|
self._attr_name = self._device.name
|
||||||
|
self._attr_device_info = DeviceInfo(
|
||||||
|
identifiers={(DOMAIN, self._uuid)},
|
||||||
|
name=self._device.name,
|
||||||
|
model=self._device.device_type,
|
||||||
|
via_device=(DOMAIN, self._lookin_device.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _async_send_command(self, command: str) -> None:
|
||||||
|
"""Send command from saved IR device."""
|
||||||
|
await self._lookin_protocol.send_command(
|
||||||
|
uuid=self._uuid, command=command, signal="FF"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LookinPowerEntity(LookinEntity):
|
||||||
|
"""A Lookin entity that has a power on and power off command."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
uuid: str,
|
||||||
|
device: Remote | Climate,
|
||||||
|
lookin_data: LookinData,
|
||||||
|
) -> None:
|
||||||
|
"""Init the power entity."""
|
||||||
|
super().__init__(uuid, device, lookin_data)
|
||||||
|
self._power_on_command: str = POWER_CMD
|
||||||
|
self._power_off_command: str = POWER_CMD
|
||||||
|
if POWER_ON_CMD in self._function_names:
|
||||||
|
self._power_on_command = POWER_ON_CMD
|
||||||
|
if POWER_OFF_CMD in self._function_names:
|
||||||
|
self._power_off_command = POWER_OFF_CMD
|
10
homeassistant/components/lookin/manifest.json
Normal file
10
homeassistant/components/lookin/manifest.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"domain": "lookin",
|
||||||
|
"name": "LOOKin",
|
||||||
|
"documentation": "https://www.home-assistant.io/integrations/lookin/",
|
||||||
|
"codeowners": ["@ANMalko"],
|
||||||
|
"requirements": ["aiolookin==0.0.2"],
|
||||||
|
"zeroconf": ["_lookin._tcp.local."],
|
||||||
|
"config_flow": true,
|
||||||
|
"iot_class": "local_push"
|
||||||
|
}
|
20
homeassistant/components/lookin/models.py
Normal file
20
homeassistant/components/lookin/models.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
"""The lookin integration models."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from aiolookin import Device, LookInHttpProtocol, LookinUDPSubscriptions
|
||||||
|
|
||||||
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LookinData:
|
||||||
|
"""Data for the lookin integration."""
|
||||||
|
|
||||||
|
lookin_udp_subs: LookinUDPSubscriptions
|
||||||
|
lookin_device: Device
|
||||||
|
meteo_coordinator: DataUpdateCoordinator
|
||||||
|
devices: list[dict[str, Any]]
|
||||||
|
lookin_protocol: LookInHttpProtocol
|
98
homeassistant/components/lookin/sensor.py
Normal file
98
homeassistant/components/lookin/sensor.py
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
"""The lookin integration sensor platform."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from aiolookin import MeteoSensor, SensorID
|
||||||
|
|
||||||
|
from homeassistant.components.sensor import (
|
||||||
|
DEVICE_CLASS_HUMIDITY,
|
||||||
|
DEVICE_CLASS_TEMPERATURE,
|
||||||
|
STATE_CLASS_MEASUREMENT,
|
||||||
|
SensorEntity,
|
||||||
|
SensorEntityDescription,
|
||||||
|
)
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import PERCENTAGE, TEMP_CELSIUS
|
||||||
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.helpers.entity import Entity
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .entity import LookinDeviceEntity
|
||||||
|
from .models import LookinData
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
|
||||||
|
SensorEntityDescription(
|
||||||
|
key="temperature",
|
||||||
|
name="Temperature",
|
||||||
|
native_unit_of_measurement=TEMP_CELSIUS,
|
||||||
|
device_class=DEVICE_CLASS_TEMPERATURE,
|
||||||
|
state_class=STATE_CLASS_MEASUREMENT,
|
||||||
|
),
|
||||||
|
SensorEntityDescription(
|
||||||
|
key="humidity",
|
||||||
|
name="Humidity",
|
||||||
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
|
device_class=DEVICE_CLASS_HUMIDITY,
|
||||||
|
state_class=STATE_CLASS_MEASUREMENT,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config_entry: ConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up lookin sensors from the config entry."""
|
||||||
|
lookin_data: LookinData = hass.data[DOMAIN][config_entry.entry_id]
|
||||||
|
|
||||||
|
async_add_entities(
|
||||||
|
[LookinSensorEntity(description, lookin_data) for description in SENSOR_TYPES]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LookinSensorEntity(CoordinatorEntity, LookinDeviceEntity, SensorEntity, Entity):
|
||||||
|
"""A lookin device sensor entity."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, description: SensorEntityDescription, lookin_data: LookinData
|
||||||
|
) -> None:
|
||||||
|
"""Init the lookin sensor entity."""
|
||||||
|
super().__init__(lookin_data.meteo_coordinator)
|
||||||
|
LookinDeviceEntity.__init__(self, lookin_data)
|
||||||
|
self.entity_description = description
|
||||||
|
self._attr_name = f"{self._lookin_device.name} {description.name}"
|
||||||
|
self._attr_native_value = getattr(self.coordinator.data, description.key)
|
||||||
|
self._attr_unique_id = f"{self._lookin_device.id}-{description.key}"
|
||||||
|
|
||||||
|
def _handle_coordinator_update(self) -> None:
|
||||||
|
"""Update the state of the entity."""
|
||||||
|
self._attr_native_value = getattr(
|
||||||
|
self.coordinator.data, self.entity_description.key
|
||||||
|
)
|
||||||
|
super()._handle_coordinator_update()
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _async_push_update(self, msg: dict[str, str]) -> None:
|
||||||
|
"""Process an update pushed via UDP."""
|
||||||
|
if int(msg["event_id"]):
|
||||||
|
return
|
||||||
|
LOGGER.debug("Processing push message for meteo sensor: %s", msg)
|
||||||
|
meteo: MeteoSensor = self.coordinator.data
|
||||||
|
meteo.update_from_value(msg["value"])
|
||||||
|
self.coordinator.async_set_updated_data(meteo)
|
||||||
|
|
||||||
|
async def async_added_to_hass(self) -> None:
|
||||||
|
"""Call when the entity is added to hass."""
|
||||||
|
self.async_on_remove(
|
||||||
|
self._lookin_udp_subs.subscribe_sensor(
|
||||||
|
self._lookin_device.id, SensorID.Meteo, None, self._async_push_update
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return await super().async_added_to_hass()
|
31
homeassistant/components/lookin/strings.json
Normal file
31
homeassistant/components/lookin/strings.json
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"flow_title": "{name} ({host})",
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"data": {
|
||||||
|
"ip_address": "[%key:common::config_flow::data::ip%]"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"device_name": {
|
||||||
|
"data": {
|
||||||
|
"name": "[%key:common::config_flow::data::name%]"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discovery_confirm": {
|
||||||
|
"description": "Do you want to setup {name} ({host})?"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
|
||||||
|
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||||
|
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
|
||||||
|
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||||
|
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||||
|
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
31
homeassistant/components/lookin/translations/en.json
Normal file
31
homeassistant/components/lookin/translations/en.json
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "Device is already configured",
|
||||||
|
"already_in_progress": "Configuration flow is already in progress",
|
||||||
|
"cannot_connect": "Failed to connect",
|
||||||
|
"no_devices_found": "No devices found on the network"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "Failed to connect",
|
||||||
|
"no_devices_found": "No devices found on the network",
|
||||||
|
"unknown": "Unexpected error"
|
||||||
|
},
|
||||||
|
"flow_title": "{name} ({host})",
|
||||||
|
"step": {
|
||||||
|
"device_name": {
|
||||||
|
"data": {
|
||||||
|
"name": "Name"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discovery_confirm": {
|
||||||
|
"description": "Do you want to setup {name} ({host})?"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"data": {
|
||||||
|
"ip_address": "IP Address"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -159,6 +159,7 @@ FLOWS = [
|
|||||||
"local_ip",
|
"local_ip",
|
||||||
"locative",
|
"locative",
|
||||||
"logi_circle",
|
"logi_circle",
|
||||||
|
"lookin",
|
||||||
"luftdaten",
|
"luftdaten",
|
||||||
"lutron_caseta",
|
"lutron_caseta",
|
||||||
"lyric",
|
"lyric",
|
||||||
|
@ -157,6 +157,11 @@ ZEROCONF = {
|
|||||||
"domain": "lutron_caseta"
|
"domain": "lutron_caseta"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"_lookin._tcp.local.": [
|
||||||
|
{
|
||||||
|
"domain": "lookin"
|
||||||
|
}
|
||||||
|
],
|
||||||
"_mediaremotetv._tcp.local.": [
|
"_mediaremotetv._tcp.local.": [
|
||||||
{
|
{
|
||||||
"domain": "apple_tv"
|
"domain": "apple_tv"
|
||||||
|
@ -206,6 +206,9 @@ aiolifx_effects==0.2.2
|
|||||||
# homeassistant.components.lutron_caseta
|
# homeassistant.components.lutron_caseta
|
||||||
aiolip==1.1.6
|
aiolip==1.1.6
|
||||||
|
|
||||||
|
# homeassistant.components.lookin
|
||||||
|
aiolookin==0.0.2
|
||||||
|
|
||||||
# homeassistant.components.lyric
|
# homeassistant.components.lyric
|
||||||
aiolyric==1.0.7
|
aiolyric==1.0.7
|
||||||
|
|
||||||
|
@ -136,6 +136,9 @@ aiokafka==0.6.0
|
|||||||
# homeassistant.components.lutron_caseta
|
# homeassistant.components.lutron_caseta
|
||||||
aiolip==1.1.6
|
aiolip==1.1.6
|
||||||
|
|
||||||
|
# homeassistant.components.lookin
|
||||||
|
aiolookin==0.0.2
|
||||||
|
|
||||||
# homeassistant.components.lyric
|
# homeassistant.components.lyric
|
||||||
aiolyric==1.0.7
|
aiolyric==1.0.7
|
||||||
|
|
||||||
|
53
tests/components/lookin/__init__.py
Normal file
53
tests/components/lookin/__init__.py
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
"""Tests for the lookin integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from aiolookin import Climate, Device, Remote
|
||||||
|
|
||||||
|
from homeassistant.components.zeroconf import HaServiceInfo
|
||||||
|
|
||||||
|
DEVICE_ID = "98F33163"
|
||||||
|
MODULE = "homeassistant.components.lookin"
|
||||||
|
MODULE_CONFIG_FLOW = "homeassistant.components.lookin.config_flow"
|
||||||
|
IP_ADDRESS = "127.0.0.1"
|
||||||
|
|
||||||
|
DEVICE_NAME = "Living Room"
|
||||||
|
DEFAULT_ENTRY_TITLE = DEVICE_NAME
|
||||||
|
|
||||||
|
ZC_NAME = f"LOOKin_{DEVICE_ID}"
|
||||||
|
ZC_TYPE = "_lookin._tcp."
|
||||||
|
ZEROCONF_DATA: HaServiceInfo = {
|
||||||
|
"host": IP_ADDRESS,
|
||||||
|
"hostname": f"{ZC_NAME.lower()}.local.",
|
||||||
|
"port": 80,
|
||||||
|
"type": ZC_TYPE,
|
||||||
|
"name": ZC_NAME,
|
||||||
|
"properties": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _mocked_climate() -> Climate:
|
||||||
|
climate = MagicMock(auto_spec=Climate)
|
||||||
|
return climate
|
||||||
|
|
||||||
|
|
||||||
|
def _mocked_remote() -> Remote:
|
||||||
|
remote = MagicMock(auto_spec=Remote)
|
||||||
|
return remote
|
||||||
|
|
||||||
|
|
||||||
|
def _mocked_device() -> Device:
|
||||||
|
device = MagicMock(auto_spec=Device)
|
||||||
|
device.name = DEVICE_NAME
|
||||||
|
device.id = DEVICE_ID
|
||||||
|
return device
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_get_info(device=None, exception=None):
|
||||||
|
async def _get_info(*args, **kwargs):
|
||||||
|
if exception:
|
||||||
|
raise exception
|
||||||
|
return device if device else _mocked_device()
|
||||||
|
|
||||||
|
return patch(f"{MODULE_CONFIG_FLOW}.LookInHttpProtocol.get_info", new=_get_info)
|
185
tests/components/lookin/test_config_flow.py
Normal file
185
tests/components/lookin/test_config_flow.py
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
"""Define tests for the lookin config flow."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from aiolookin import NoUsableService
|
||||||
|
|
||||||
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.components.lookin.const import DOMAIN
|
||||||
|
from homeassistant.const import CONF_HOST
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.data_entry_flow import (
|
||||||
|
RESULT_TYPE_ABORT,
|
||||||
|
RESULT_TYPE_CREATE_ENTRY,
|
||||||
|
RESULT_TYPE_FORM,
|
||||||
|
)
|
||||||
|
|
||||||
|
from . import (
|
||||||
|
DEFAULT_ENTRY_TITLE,
|
||||||
|
DEVICE_ID,
|
||||||
|
IP_ADDRESS,
|
||||||
|
MODULE,
|
||||||
|
ZEROCONF_DATA,
|
||||||
|
_patch_get_info,
|
||||||
|
)
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
|
||||||
|
async def test_manual_setup(hass: HomeAssistant):
|
||||||
|
"""Test manually setting up."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["step_id"] == "user"
|
||||||
|
assert not result["errors"]
|
||||||
|
|
||||||
|
with _patch_get_info(), patch(
|
||||||
|
f"{MODULE}.async_setup_entry", return_value=True
|
||||||
|
) as mock_setup_entry:
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], {CONF_HOST: IP_ADDRESS}
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
|
||||||
|
assert result["data"] == {CONF_HOST: IP_ADDRESS}
|
||||||
|
assert result["title"] == DEFAULT_ENTRY_TITLE
|
||||||
|
assert len(mock_setup_entry.mock_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_manual_setup_already_exists(hass: HomeAssistant):
|
||||||
|
"""Test manually setting up and the device already exists."""
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN, data={CONF_HOST: IP_ADDRESS}, unique_id=DEVICE_ID
|
||||||
|
)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["step_id"] == "user"
|
||||||
|
assert not result["errors"]
|
||||||
|
|
||||||
|
with _patch_get_info():
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], {CONF_HOST: IP_ADDRESS}
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert result["type"] == RESULT_TYPE_ABORT
|
||||||
|
assert result["reason"] == "already_configured"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_manual_setup_device_offline(hass: HomeAssistant):
|
||||||
|
"""Test manually setting up, device offline."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["step_id"] == "user"
|
||||||
|
assert not result["errors"]
|
||||||
|
|
||||||
|
with _patch_get_info(exception=NoUsableService):
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], {CONF_HOST: IP_ADDRESS}
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["errors"] == {CONF_HOST: "cannot_connect"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_manual_setup_unknown_exception(hass: HomeAssistant):
|
||||||
|
"""Test manually setting up, unknown exception."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["step_id"] == "user"
|
||||||
|
assert not result["errors"]
|
||||||
|
|
||||||
|
with _patch_get_info(exception=Exception):
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], {CONF_HOST: IP_ADDRESS}
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["errors"] == {"base": "unknown"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_discovered_zeroconf(hass):
|
||||||
|
"""Test we can setup when discovered from zeroconf."""
|
||||||
|
|
||||||
|
with _patch_get_info():
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": config_entries.SOURCE_ZEROCONF},
|
||||||
|
data=ZEROCONF_DATA,
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert result["type"] == RESULT_TYPE_FORM
|
||||||
|
assert result["errors"] is None
|
||||||
|
|
||||||
|
with _patch_get_info(), patch(
|
||||||
|
f"{MODULE}.async_setup_entry", return_value=True
|
||||||
|
) as mock_async_setup_entry:
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {})
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert result2["type"] == RESULT_TYPE_CREATE_ENTRY
|
||||||
|
assert result2["data"] == {CONF_HOST: IP_ADDRESS}
|
||||||
|
assert result2["title"] == DEFAULT_ENTRY_TITLE
|
||||||
|
assert mock_async_setup_entry.called
|
||||||
|
|
||||||
|
entry = hass.config_entries.async_entries(DOMAIN)[0]
|
||||||
|
zc_data_new_ip = ZEROCONF_DATA.copy()
|
||||||
|
zc_data_new_ip["host"] = "127.0.0.2"
|
||||||
|
|
||||||
|
with _patch_get_info(), patch(
|
||||||
|
f"{MODULE}.async_setup_entry", return_value=True
|
||||||
|
) as mock_async_setup_entry:
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": config_entries.SOURCE_ZEROCONF},
|
||||||
|
data=zc_data_new_ip,
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert result["type"] == RESULT_TYPE_ABORT
|
||||||
|
assert result["reason"] == "already_configured"
|
||||||
|
assert entry.data[CONF_HOST] == "127.0.0.2"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_discovered_zeroconf_cannot_connect(hass):
|
||||||
|
"""Test we abort if we cannot connect when discovered from zeroconf."""
|
||||||
|
|
||||||
|
with _patch_get_info(exception=NoUsableService):
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": config_entries.SOURCE_ZEROCONF},
|
||||||
|
data=ZEROCONF_DATA,
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert result["type"] == RESULT_TYPE_ABORT
|
||||||
|
assert result["reason"] == "cannot_connect"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_discovered_zeroconf_unknown_exception(hass):
|
||||||
|
"""Test we abort if we get an unknown exception when discovered from zeroconf."""
|
||||||
|
|
||||||
|
with _patch_get_info(exception=Exception):
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": config_entries.SOURCE_ZEROCONF},
|
||||||
|
data=ZEROCONF_DATA,
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert result["type"] == RESULT_TYPE_ABORT
|
||||||
|
assert result["reason"] == "unknown"
|
Reference in New Issue
Block a user