diff --git a/.coveragerc b/.coveragerc index a979845326b..507c216d0fe 100644 --- a/.coveragerc +++ b/.coveragerc @@ -949,6 +949,10 @@ omit = homeassistant/components/sense/sensor.py homeassistant/components/sensehat/light.py homeassistant/components/sensehat/sensor.py + homeassistant/components/senseme/__init__.py + homeassistant/components/senseme/discovery.py + homeassistant/components/senseme/entity.py + homeassistant/components/senseme/fan.py homeassistant/components/sensibo/__init__.py homeassistant/components/sensibo/climate.py homeassistant/components/serial/sensor.py diff --git a/.strict-typing b/.strict-typing index 5879c9899ae..339622c0f11 100644 --- a/.strict-typing +++ b/.strict-typing @@ -123,6 +123,7 @@ homeassistant.components.samsungtv.* homeassistant.components.scene.* homeassistant.components.select.* homeassistant.components.sensor.* +homeassistant.components.senseme.* homeassistant.components.shelly.* homeassistant.components.simplisafe.* homeassistant.components.slack.* diff --git a/CODEOWNERS b/CODEOWNERS index 1bc9d17735f..999430d0f3b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -801,6 +801,8 @@ homeassistant/components/select/* @home-assistant/core tests/components/select/* @home-assistant/core homeassistant/components/sense/* @kbickar tests/components/sense/* @kbickar +homeassistant/components/senseme/* @mikelawrence @bdraco +tests/components/senseme/* @mikelawrence @bdraco homeassistant/components/sensibo/* @andrey-git @gjohansson-ST tests/components/sensibo/* @andrey-git @gjohansson-ST homeassistant/components/sentry/* @dcramer @frenck diff --git a/homeassistant/components/senseme/__init__.py b/homeassistant/components/senseme/__init__.py new file mode 100644 index 00000000000..7a64a23002f --- /dev/null +++ b/homeassistant/components/senseme/__init__.py @@ -0,0 +1,36 @@ +"""The SenseME integration.""" +from __future__ import annotations + +from aiosenseme import async_get_device_by_device_info + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import CONF_INFO, DOMAIN, PLATFORMS, UPDATE_RATE +from .discovery import async_start_discovery + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up SenseME from a config entry.""" + async_start_discovery(hass) + + status, device = await async_get_device_by_device_info( + info=entry.data[CONF_INFO], start_first=True, refresh_minutes=UPDATE_RATE + ) + if not status: + device.stop() + raise ConfigEntryNotReady(f"Connect to address {device.address} failed") + + await device.async_update(not status) + + hass.data[DOMAIN][entry.entry_id] = device + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + hass.data[DOMAIN][entry.entry_id].stop() + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/senseme/config_flow.py b/homeassistant/components/senseme/config_flow.py new file mode 100644 index 00000000000..adaa7ef036a --- /dev/null +++ b/homeassistant/components/senseme/config_flow.py @@ -0,0 +1,135 @@ +"""Config flow for SenseME.""" +from __future__ import annotations + +import ipaddress +from typing import Any + +from aiosenseme import SensemeDevice, async_get_device_by_ip_address +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_ID +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.typing import DiscoveryInfoType + +from .const import CONF_HOST_MANUAL, CONF_INFO, DOMAIN +from .discovery import async_discover, async_get_discovered_device + +DISCOVER_TIMEOUT = 5 + + +class SensemeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle SenseME discovery config flow.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the SenseME config flow.""" + self._discovered_devices: list[SensemeDevice] | None = None + self._discovered_device: SensemeDevice | None = None + + async def async_step_discovery( + self, discovery_info: DiscoveryInfoType + ) -> FlowResult: + """Handle discovery.""" + uuid = discovery_info[CONF_ID] + device = async_get_discovered_device(self.hass, discovery_info[CONF_ID]) + host = device.address + await self.async_set_unique_id(uuid) + for entry in self._async_current_entries(include_ignore=False): + if entry.data[CONF_INFO]["address"] == host: + return self.async_abort(reason="already_configured") + if entry.unique_id != uuid: + continue + if entry.data[CONF_INFO]["address"] != host: + self.hass.config_entries.async_update_entry( + entry, data={CONF_INFO: {**entry.data[CONF_INFO], "address": host}} + ) + return self.async_abort(reason="already_configured") + self._discovered_device = device + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm discovery.""" + device = self._discovered_device + assert device is not None + + if user_input is not None: + return await self._async_entry_for_device(device) + placeholders = { + "name": device.name, + "model": device.model, + "host": device.address, + } + self.context["title_placeholders"] = placeholders + return self.async_show_form( + step_id="discovery_confirm", description_placeholders=placeholders + ) + + async def _async_entry_for_device(self, device: SensemeDevice) -> FlowResult: + """Create a config entry for a device.""" + await self.async_set_unique_id(device.uuid, raise_on_progress=False) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=device.name, + data={CONF_INFO: device.get_device_info}, + ) + + async def async_step_manual( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle manual entry of an ip address.""" + errors = {} + if user_input is not None: + host = user_input[CONF_HOST] + try: + ipaddress.ip_address(host) + except ValueError: + errors[CONF_HOST] = "invalid_host" + else: + if device := await async_get_device_by_ip_address(host): + device.stop() + return await self._async_entry_for_device(device) + + errors[CONF_HOST] = "cannot_connect" + + return self.async_show_form( + step_id="manual", + data_schema=vol.Schema({vol.Required(CONF_HOST): str}), + errors=errors, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + if self._discovered_devices is None: + self._discovered_devices = await async_discover(self.hass, DISCOVER_TIMEOUT) + current_ids = self._async_current_ids() + device_selection = { + device.uuid: device.name + for device in self._discovered_devices + if device.uuid not in current_ids + } + + if not device_selection: + return await self.async_step_manual(user_input=None) + + device_selection[None] = CONF_HOST_MANUAL + + if user_input is not None: + if user_input[CONF_DEVICE] is None: + return await self.async_step_manual() + + for device in self._discovered_devices: + if device.uuid == user_input[CONF_DEVICE]: + return await self._async_entry_for_device(device) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_DEVICE): vol.In(device_selection)} + ), + ) diff --git a/homeassistant/components/senseme/const.py b/homeassistant/components/senseme/const.py new file mode 100644 index 00000000000..9c7de9b2720 --- /dev/null +++ b/homeassistant/components/senseme/const.py @@ -0,0 +1,23 @@ +"""Constants for the SenseME integration.""" + + +from homeassistant.const import Platform + +DOMAIN = "senseme" + +# Periodic fan update rate in minutes +UPDATE_RATE = 1 + +# data storage +CONF_INFO = "info" +CONF_HOST_MANUAL = "IP Address" +DISCOVERY = "discovery" + +# Fan Preset Modes +PRESET_MODE_WHOOSH = "Whoosh" + +# Fan Directions +SENSEME_DIRECTION_FORWARD = "FWD" +SENSEME_DIRECTION_REVERSE = "REV" + +PLATFORMS = [Platform.FAN] diff --git a/homeassistant/components/senseme/discovery.py b/homeassistant/components/senseme/discovery.py new file mode 100644 index 00000000000..15180d28f11 --- /dev/null +++ b/homeassistant/components/senseme/discovery.py @@ -0,0 +1,63 @@ +"""The SenseME integration discovery.""" +from __future__ import annotations + +import asyncio + +from aiosenseme import SensemeDevice, SensemeDiscovery + +from homeassistant import config_entries +from homeassistant.const import CONF_ID +from homeassistant.core import HomeAssistant, callback + +from .const import DISCOVERY, DOMAIN + + +@callback +def async_start_discovery(hass: HomeAssistant) -> bool: + """Start discovery if its not already running.""" + domain_data = hass.data.setdefault(DOMAIN, {}) + if DISCOVERY in domain_data: + return False # already running + discovery = domain_data[DISCOVERY] = SensemeDiscovery(False) + discovery.add_callback(lambda devices: async_trigger_discovery(hass, devices)) + discovery.start() + return True # started + + +@callback +def async_get_discovered_device(hass: HomeAssistant, uuid: str) -> SensemeDevice: + """Return a discovered device.""" + discovery: SensemeDiscovery = hass.data[DOMAIN][DISCOVERY] + devices: list[SensemeDevice] = discovery.devices + for discovered_device in devices: + if discovered_device.uuid == uuid: + return discovered_device + raise RuntimeError("Discovered device unexpectedly disappeared") + + +async def async_discover(hass: HomeAssistant, timeout: float) -> list[SensemeDevice]: + """Discover devices or restart it if its already running.""" + started = async_start_discovery(hass) + discovery: SensemeDiscovery = hass.data[DOMAIN][DISCOVERY] + if not started: # already running + discovery.stop() + discovery.start() + await asyncio.sleep(timeout) + devices: list[SensemeDevice] = discovery.devices + return devices + + +@callback +def async_trigger_discovery( + hass: HomeAssistant, + discovered_devices: list[SensemeDevice], +) -> None: + """Trigger config flows for discovered devices.""" + for device in discovered_devices: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DISCOVERY}, + data={CONF_ID: device.uuid}, + ) + ) diff --git a/homeassistant/components/senseme/entity.py b/homeassistant/components/senseme/entity.py new file mode 100644 index 00000000000..d0542464a86 --- /dev/null +++ b/homeassistant/components/senseme/entity.py @@ -0,0 +1,54 @@ +"""The SenseME integration entities.""" +from __future__ import annotations + +from aiosenseme import SensemeDevice + +from homeassistant.core import callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity import DeviceInfo, Entity + + +class SensemeEntity(Entity): + """Base class for senseme entities.""" + + _attr_should_poll = False + + def __init__(self, device: SensemeDevice, name: str) -> None: + """Initialize the entity.""" + self._device = device + self._attr_name = name + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, self._device.mac)}, + name=self._device.name, + manufacturer="Big Ass Fans", + model=self._device.model, + sw_version=self._device.fw_version, + suggested_area=self._device.room_name, + ) + + @property + def extra_state_attributes(self) -> dict: + """Get the current device state attributes.""" + return { + "room_name": self._device.room_name, + "room_type": self._device.room_type, + } + + @callback + def _async_update_attrs(self) -> None: + """Update attrs from device.""" + self._attr_available = self._device.available + + @callback + def _async_update_from_device(self) -> None: + """Process an update from the device.""" + self._async_update_attrs() + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Add data updated listener after this object has been initialized.""" + self._device.add_callback(self._async_update_from_device) + + async def async_will_remove_from_hass(self) -> None: + """Remove data updated listener after this object has been initialized.""" + self._device.remove_callback(self._async_update_from_device) diff --git a/homeassistant/components/senseme/fan.py b/homeassistant/components/senseme/fan.py new file mode 100644 index 00000000000..a102b650ddf --- /dev/null +++ b/homeassistant/components/senseme/fan.py @@ -0,0 +1,125 @@ +"""Support for Big Ass Fans SenseME fan.""" +from __future__ import annotations + +import math +from typing import Any + +from aiosenseme import SensemeFan + +from homeassistant import config_entries +from homeassistant.components.fan import ( + DIRECTION_FORWARD, + DIRECTION_REVERSE, + SUPPORT_DIRECTION, + SUPPORT_SET_SPEED, + FanEntity, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.percentage import ( + percentage_to_ranged_value, + ranged_value_to_percentage, +) + +from .const import ( + DOMAIN, + PRESET_MODE_WHOOSH, + SENSEME_DIRECTION_FORWARD, + SENSEME_DIRECTION_REVERSE, +) +from .entity import SensemeEntity + +SENSEME_DIRECTION_TO_HASS = { + SENSEME_DIRECTION_FORWARD: DIRECTION_FORWARD, + SENSEME_DIRECTION_REVERSE: DIRECTION_REVERSE, +} +HASS_DIRECTION_TO_SENSEME = {v: k for k, v in SENSEME_DIRECTION_TO_HASS.items()} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up SenseME fans.""" + device = hass.data[DOMAIN][entry.entry_id] + if device.is_fan: + async_add_entities([HASensemeFan(device)]) + + +class HASensemeFan(SensemeEntity, FanEntity): + """SenseME ceiling fan component.""" + + _attr_supported_features = SUPPORT_SET_SPEED | SUPPORT_DIRECTION + _attr_preset_modes = [PRESET_MODE_WHOOSH] + + def __init__(self, device: SensemeFan) -> None: + """Initialize the entity.""" + super().__init__(device, device.name) + self._attr_speed_count = self._device.fan_speed_max + self._attr_unique_id = f"{self._device.uuid}-FAN" # for legacy compat + self._async_update_attrs() + + @callback + def _async_update_attrs(self) -> None: + """Update attrs from device.""" + self._attr_is_on = self._device.fan_on + self._attr_current_direction = SENSEME_DIRECTION_TO_HASS.get( + self._device.fan_dir, DIRECTION_FORWARD # None also means forward + ) + if self._device.fan_speed is not None: + self._attr_percentage = ranged_value_to_percentage( + self._device.fan_speed_limits, self._device.fan_speed + ) + else: + self._attr_percentage = None + whoosh = self._device.fan_whoosh_mode + self._attr_preset_mode = whoosh if whoosh else None + super()._async_update_attrs() + + @property + def extra_state_attributes(self) -> dict: + """Get the current device state attributes.""" + return { + "auto_comfort": self._device.fan_autocomfort.capitalize(), + "smartmode": self._device.fan_smartmode.capitalize(), + **super().extra_state_attributes, + } + + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed of the fan, as a percentage.""" + self._device.fan_speed = math.ceil( + percentage_to_ranged_value(self._device.fan_speed_limits, percentage) + ) + + async def async_turn_on( + self, + speed: str | None = None, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn the fan on with a percentage or preset mode.""" + if preset_mode is not None: + await self.async_set_preset_mode(preset_mode) + elif percentage is None: + self._device.fan_on = True + else: + await self.async_set_percentage(percentage) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the fan off.""" + self._device.fan_on = False + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode of the fan.""" + if preset_mode != PRESET_MODE_WHOOSH: + raise ValueError(f"Invalid preset mode: {preset_mode}") + # Sleep mode must be off for Whoosh to work. + if self._device.sleep_mode: + self._device.sleep_mode = False + self._device.fan_whoosh_mode = True + + async def async_set_direction(self, direction: str) -> None: + """Set the direction of the fan.""" + self._device.fan_dir = HASS_DIRECTION_TO_SENSEME[direction] diff --git a/homeassistant/components/senseme/manifest.json b/homeassistant/components/senseme/manifest.json new file mode 100644 index 00000000000..a5ac179d9a0 --- /dev/null +++ b/homeassistant/components/senseme/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "senseme", + "name": "SenseME", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/senseme", + "requirements": [ + "aiosenseme==0.5.5" + ], + "codeowners": [ + "@mikelawrence", "@bdraco" + ], + "iot_class": "local_push" +} \ No newline at end of file diff --git a/homeassistant/components/senseme/strings.json b/homeassistant/components/senseme/strings.json new file mode 100644 index 00000000000..52d8afb443a --- /dev/null +++ b/homeassistant/components/senseme/strings.json @@ -0,0 +1,29 @@ +{ + "config": { + "flow_title": "{name} - {model} ({host})", + "step": { + "user": { + "description": "Select a device, or choose 'IP Address' to manually enter an IP Address.", + "data": { + "device": "Device" + } + }, + "discovery_confirm": { + "description": "Do you want to setup {name} - {model} ({host})?" + }, + "manual": { + "description": "Enter an IP Address.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/senseme/translations/en.json b/homeassistant/components/senseme/translations/en.json new file mode 100644 index 00000000000..0136d4d29cb --- /dev/null +++ b/homeassistant/components/senseme/translations/en.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_host": "Invalid hostname or IP address" + }, + "flow_title": "{name} - {model} ({host})", + "step": { + "discovery_confirm": { + "description": "Do you want to setup {name} - {model} ({host})?" + }, + "manual": { + "data": { + "host": "Host" + }, + "description": "Enter an IP Address." + }, + "user": { + "data": { + "device": "Device" + }, + "description": "Select a device, or choose 'IP Address' to manually enter an IP Address." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index df59ade9338..49166a5cb73 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -269,6 +269,7 @@ FLOWS = [ "samsungtv", "screenlogic", "sense", + "senseme", "sensibo", "sentry", "sharkiq", diff --git a/mypy.ini b/mypy.ini index 166fa96c0c0..1ad9fc03c72 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1364,6 +1364,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.senseme.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.shelly.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index c65214df8dd..c8986b16a54 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -250,6 +250,9 @@ aiorecollect==1.0.8 # homeassistant.components.ridwell aioridwell==2021.12.2 +# homeassistant.components.senseme +aiosenseme==0.5.5 + # homeassistant.components.shelly aioshelly==1.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1717495e062..bbbdd624809 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -182,6 +182,9 @@ aiorecollect==1.0.8 # homeassistant.components.ridwell aioridwell==2021.12.2 +# homeassistant.components.senseme +aiosenseme==0.5.5 + # homeassistant.components.shelly aioshelly==1.0.7 diff --git a/tests/components/senseme/__init__.py b/tests/components/senseme/__init__.py new file mode 100644 index 00000000000..2286b1ad890 --- /dev/null +++ b/tests/components/senseme/__init__.py @@ -0,0 +1,117 @@ +"""Tests for the SenseME integration.""" + +from contextlib import contextmanager +from unittest.mock import AsyncMock, MagicMock, patch + +from aiosenseme import SensemeDevice, SensemeDiscovery + +from homeassistant.components.senseme import config_flow + +MOCK_NAME = "Haiku Fan" +MOCK_UUID = "77a6b7b3-925d-4695-a415-76d76dca4444" +MOCK_ADDRESS = "127.0.0.1" + +device = MagicMock(auto_spec=SensemeDevice) +device.async_update = AsyncMock() +device.model = "Haiku Fan" +device.fan_speed_max = 7 +device.mac = "aa:bb:cc:dd:ee:ff" +device.fan_dir = "REV" +device.room_name = "Main" +device.room_type = "Main" +device.fw_version = "1" +device.fan_autocomfort = "on" +device.fan_smartmode = "on" +device.fan_whoosh_mode = "on" +device.name = MOCK_NAME +device.uuid = MOCK_UUID +device.address = MOCK_ADDRESS +device.get_device_info = { + "name": MOCK_NAME, + "uuid": MOCK_UUID, + "mac": "20:F8:5E:92:5A:75", + "address": MOCK_ADDRESS, + "base_model": "FAN,HAIKU,HSERIES", + "has_light": False, + "has_sensor": True, + "is_fan": True, + "is_light": False, +} + + +device_alternate_ip = MagicMock(auto_spec=SensemeDevice) +device_alternate_ip.async_update = AsyncMock() +device_alternate_ip.model = "Haiku Fan" +device_alternate_ip.fan_speed_max = 7 +device_alternate_ip.mac = "aa:bb:cc:dd:ee:ff" +device_alternate_ip.fan_dir = "REV" +device_alternate_ip.room_name = "Main" +device_alternate_ip.room_type = "Main" +device_alternate_ip.fw_version = "1" +device_alternate_ip.fan_autocomfort = "on" +device_alternate_ip.fan_smartmode = "on" +device_alternate_ip.fan_whoosh_mode = "on" +device_alternate_ip.name = MOCK_NAME +device_alternate_ip.uuid = MOCK_UUID +device_alternate_ip.address = "127.0.0.8" +device_alternate_ip.get_device_info = { + "name": MOCK_NAME, + "uuid": MOCK_UUID, + "mac": "20:F8:5E:92:5A:75", + "address": "127.0.0.8", + "base_model": "FAN,HAIKU,HSERIES", + "has_light": False, + "has_sensor": True, + "is_fan": True, + "is_light": False, +} + + +device2 = MagicMock(auto_spec=SensemeDevice) +device2.async_update = AsyncMock() +device2.model = "Haiku Fan" +device2.fan_speed_max = 7 +device2.mac = "aa:bb:cc:dd:ee:ff" +device2.fan_dir = "FWD" +device2.room_name = "Main" +device2.room_type = "Main" +device2.fw_version = "1" +device2.fan_autocomfort = "on" +device2.fan_smartmode = "on" +device2.fan_whoosh_mode = "on" +device2.name = "Device 2" +device2.uuid = "uuid2" +device2.address = "127.0.0.2" +device2.get_device_info = { + "name": "Device 2", + "uuid": "uuid2", + "mac": "20:F8:5E:92:5A:76", + "address": "127.0.0.2", + "base_model": "FAN,HAIKU,HSERIES", + "has_light": True, + "has_sensor": True, + "is_fan": True, + "is_light": False, +} + +MOCK_DEVICE = device +MOCK_DEVICE_ALTERNATE_IP = device_alternate_ip +MOCK_DEVICE2 = device2 + + +def _patch_discovery(device=None, no_device=None): + """Patch discovery.""" + mock_senseme_discovery = MagicMock(auto_spec=SensemeDiscovery) + if not no_device: + mock_senseme_discovery.devices = [device or MOCK_DEVICE] + + @contextmanager + def _patcher(): + + with patch.object(config_flow, "DISCOVER_TIMEOUT", 0), patch( + "homeassistant.components.senseme.discovery.SensemeDiscovery", + return_value=mock_senseme_discovery, + ): + yield + + return _patcher() diff --git a/tests/components/senseme/test_config_flow.py b/tests/components/senseme/test_config_flow.py new file mode 100644 index 00000000000..12d61660fc9 --- /dev/null +++ b/tests/components/senseme/test_config_flow.py @@ -0,0 +1,280 @@ +"""Test the SenseME config flow.""" +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components.senseme.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_ID +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from . import ( + MOCK_ADDRESS, + MOCK_DEVICE, + MOCK_DEVICE2, + MOCK_DEVICE_ALTERNATE_IP, + MOCK_UUID, + _patch_discovery, +) + +from tests.common import MockConfigEntry + + +async def test_form_user(hass: HomeAssistant) -> None: + """Test we get the form as a user.""" + + with _patch_discovery(), patch( + "homeassistant.components.senseme.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert not result["errors"] + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "device": MOCK_UUID, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "Haiku Fan" + assert result2["data"] == { + "info": MOCK_DEVICE.get_device_info, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_user_manual_entry(hass: HomeAssistant) -> None: + """Test we get the form as a user with a discovery but user chooses manual.""" + + with _patch_discovery(): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert not result["errors"] + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "device": None, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["step_id"] == "manual" + + with patch( + "homeassistant.components.senseme.config_flow.async_get_device_by_ip_address", + return_value=MOCK_DEVICE, + ), patch( + "homeassistant.components.senseme.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: MOCK_ADDRESS, + }, + ) + await hass.async_block_till_done() + + assert result3["title"] == "Haiku Fan" + assert result3["data"] == { + "info": MOCK_DEVICE.get_device_info, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_user_no_discovery(hass: HomeAssistant) -> None: + """Test we get the form as a user with no discovery.""" + + with _patch_discovery(no_device=True), patch( + "homeassistant.components.senseme.config_flow.async_get_device_by_ip_address", + return_value=MOCK_DEVICE, + ), patch( + "homeassistant.components.senseme.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert not result["errors"] + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "not a valid address", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["step_id"] == "manual" + assert result2["errors"] == {CONF_HOST: "invalid_host"} + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_HOST: MOCK_ADDRESS, + }, + ) + await hass.async_block_till_done() + + assert result3["title"] == "Haiku Fan" + assert result3["data"] == { + "info": MOCK_DEVICE.get_device_info, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_user_manual_entry_cannot_connect(hass: HomeAssistant) -> None: + """Test we get the form as a user.""" + + with _patch_discovery(): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert not result["errors"] + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "device": None, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["step_id"] == "manual" + + with patch( + "homeassistant.components.senseme.config_flow.async_get_device_by_ip_address", + return_value=None, + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: MOCK_ADDRESS, + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == RESULT_TYPE_FORM + assert result3["step_id"] == "manual" + assert result3["errors"] == {CONF_HOST: "cannot_connect"} + + +async def test_discovery(hass: HomeAssistant) -> None: + """Test we can setup a discovered device.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "info": MOCK_DEVICE2.get_device_info, + }, + unique_id=MOCK_DEVICE2.uuid, + ) + entry.add_to_hass(hass) + + with _patch_discovery(), patch( + "homeassistant.components.senseme.async_get_device_by_device_info", + return_value=(True, MOCK_DEVICE2), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + with _patch_discovery(), patch( + "homeassistant.components.senseme.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DISCOVERY}, + data={CONF_ID: MOCK_UUID}, + ) + assert result["type"] == RESULT_TYPE_FORM + assert not result["errors"] + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "device": MOCK_UUID, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "Haiku Fan" + assert result2["data"] == { + "info": MOCK_DEVICE.get_device_info, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_discovery_existing_device_no_ip_change(hass: HomeAssistant) -> None: + """Test we can setup a discovered device.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "info": MOCK_DEVICE.get_device_info, + }, + unique_id=MOCK_DEVICE.uuid, + ) + entry.add_to_hass(hass) + + with _patch_discovery(), patch( + "homeassistant.components.senseme.async_get_device_by_device_info", + return_value=(True, MOCK_DEVICE), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + with _patch_discovery(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DISCOVERY}, + data={CONF_ID: MOCK_UUID}, + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_discovery_existing_device_ip_change(hass: HomeAssistant) -> None: + """Test a config entry ips get updated from discovery.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "info": MOCK_DEVICE.get_device_info, + }, + unique_id=MOCK_DEVICE.uuid, + ) + entry.add_to_hass(hass) + + with _patch_discovery(device=MOCK_DEVICE_ALTERNATE_IP), patch( + "homeassistant.components.senseme.async_get_device_by_device_info", + return_value=(True, MOCK_DEVICE), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DISCOVERY}, + data={CONF_ID: MOCK_UUID}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert entry.data["info"]["address"] == "127.0.0.8"