diff --git a/.coveragerc b/.coveragerc index 67bea8dae16089ed2f9cfa6ccb51ad18873666f6..ece1c1258211882a26badd3dccf0c0260cce6dc5 100644 --- a/.coveragerc +++ b/.coveragerc @@ -214,7 +214,14 @@ omit = homeassistant/components/emoncms_history/* homeassistant/components/emulated_hue/upnp.py homeassistant/components/enigma2/media_player.py - homeassistant/components/enocean/* + homeassistant/components/enocean/__init__.py + homeassistant/components/enocean/binary_sensor.py + homeassistant/components/enocean/const.py + homeassistant/components/enocean/device.py + homeassistant/components/enocean/dongle.py + homeassistant/components/enocean/light.py + homeassistant/components/enocean/sensor.py + homeassistant/components/enocean/switch.py homeassistant/components/enphase_envoy/sensor.py homeassistant/components/entur_public_transport/* homeassistant/components/environment_canada/* diff --git a/homeassistant/components/enocean/__init__.py b/homeassistant/components/enocean/__init__.py index 90ab408775414a39b38e5de7525f40d4c32e7b78..c13f71a14321c8b89c8b8fd1f704975a86c4a234 100644 --- a/homeassistant/components/enocean/__init__.py +++ b/homeassistant/components/enocean/__init__.py @@ -1,93 +1,57 @@ """Support for EnOcean devices.""" -import logging -from enocean.communicators.serialcommunicator import SerialCommunicator -from enocean.protocol.packet import Packet, RadioPacket -from enocean.utils import combine_hex import voluptuous as vol +from homeassistant import config_entries, core +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_DEVICE import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity -_LOGGER = logging.getLogger(__name__) - -DOMAIN = "enocean" -DATA_ENOCEAN = "enocean" +from .const import DATA_ENOCEAN, DOMAIN, ENOCEAN_DONGLE +from .dongle import EnOceanDongle CONFIG_SCHEMA = vol.Schema( {DOMAIN: vol.Schema({vol.Required(CONF_DEVICE): cv.string})}, extra=vol.ALLOW_EXTRA ) -SIGNAL_RECEIVE_MESSAGE = "enocean.receive_message" -SIGNAL_SEND_MESSAGE = "enocean.send_message" - -def setup(hass, config): +async def async_setup(hass, config): """Set up the EnOcean component.""" - serial_dev = config[DOMAIN].get(CONF_DEVICE) - dongle = EnOceanDongle(hass, serial_dev) - hass.data[DATA_ENOCEAN] = dongle - - return True - - -class EnOceanDongle: - """Representation of an EnOcean dongle.""" - - def __init__(self, hass, ser): - """Initialize the EnOcean dongle.""" - - self.__communicator = SerialCommunicator(port=ser, callback=self.callback) - self.__communicator.start() - self.hass = hass - self.hass.helpers.dispatcher.dispatcher_connect( - SIGNAL_SEND_MESSAGE, self._send_message_callback + # support for text-based configuration (legacy) + if DOMAIN not in config: + return True + + if hass.config_entries.async_entries(DOMAIN): + # We can only have one dongle. If there is already one in the config, + # there is no need to import the yaml based config. + return True + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN] ) + ) - def _send_message_callback(self, command): - """Send a command through the EnOcean dongle.""" - self.__communicator.send(command) - - def callback(self, packet): - """Handle EnOcean device's callback. - - This is the callback function called by python-enocan whenever there - is an incoming packet. - """ - - if isinstance(packet, RadioPacket): - _LOGGER.debug("Received radio packet: %s", packet) - self.hass.helpers.dispatcher.dispatcher_send(SIGNAL_RECEIVE_MESSAGE, packet) - - -class EnOceanDevice(Entity): - """Parent class for all devices associated with the EnOcean component.""" + return True - def __init__(self, dev_id, dev_name="EnOcean device"): - """Initialize the device.""" - self.dev_id = dev_id - self.dev_name = dev_name - async def async_added_to_hass(self): - """Register callbacks.""" - self.async_on_remove( - self.hass.helpers.dispatcher.async_dispatcher_connect( - SIGNAL_RECEIVE_MESSAGE, self._message_received_callback - ) - ) +async def async_setup_entry( + hass: core.HomeAssistant, config_entry: config_entries.ConfigEntry +): + """Set up an EnOcean dongle for the given entry.""" + enocean_data = hass.data.setdefault(DATA_ENOCEAN, {}) + usb_dongle = EnOceanDongle(hass, config_entry.data[CONF_DEVICE]) + await usb_dongle.async_setup() + enocean_data[ENOCEAN_DONGLE] = usb_dongle - def _message_received_callback(self, packet): - """Handle incoming packets.""" + return True - if packet.sender_int == combine_hex(self.dev_id): - self.value_changed(packet) - def value_changed(self, packet): - """Update the internal state of the device when a packet arrives.""" +async def async_unload_entry(hass, config_entry): + """Unload ENOcean config entry.""" - def send_command(self, data, optional, packet_type): - """Send a command via the EnOcean dongle.""" + enocean_dongle = hass.data[DATA_ENOCEAN][ENOCEAN_DONGLE] + enocean_dongle.unload() + hass.data.pop(DATA_ENOCEAN) - packet = Packet(packet_type, data=data, optional=optional) - self.hass.helpers.dispatcher.dispatcher_send(SIGNAL_SEND_MESSAGE, packet) + return True diff --git a/homeassistant/components/enocean/binary_sensor.py b/homeassistant/components/enocean/binary_sensor.py index 7fb8ea5e3f2fa68305e29b544a45808a73128b53..31bd6607ae2a575ec0c9d3b294396ff477c520d4 100644 --- a/homeassistant/components/enocean/binary_sensor.py +++ b/homeassistant/components/enocean/binary_sensor.py @@ -3,7 +3,6 @@ import logging import voluptuous as vol -from homeassistant.components import enocean from homeassistant.components.binary_sensor import ( DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA, @@ -12,6 +11,8 @@ from homeassistant.components.binary_sensor import ( from homeassistant.const import CONF_DEVICE_CLASS, CONF_ID, CONF_NAME import homeassistant.helpers.config_validation as cv +from .device import EnOceanEntity + _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "EnOcean binary sensor" @@ -36,7 +37,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities([EnOceanBinarySensor(dev_id, dev_name, device_class)]) -class EnOceanBinarySensor(enocean.EnOceanDevice, BinarySensorEntity): +class EnOceanBinarySensor(EnOceanEntity, BinarySensorEntity): """Representation of EnOcean binary sensors such as wall switches. Supported EEPs (EnOcean Equipment Profiles): diff --git a/homeassistant/components/enocean/config_flow.py b/homeassistant/components/enocean/config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..7fce66d54e5b290e675d0171bdb32dbfc0459f3f --- /dev/null +++ b/homeassistant/components/enocean/config_flow.py @@ -0,0 +1,94 @@ +"""Config flows for the ENOcean integration.""" + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.config_entries import CONN_CLASS_ASSUMED +from homeassistant.const import CONF_DEVICE + +from . import dongle +from .const import DOMAIN # pylint:disable=unused-import +from .const import ERROR_INVALID_DONGLE_PATH, LOGGER + + +class EnOceanFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle the enOcean config flows.""" + + VERSION = 1 + MANUAL_PATH_VALUE = "Custom path" + CONNECTION_CLASS = CONN_CLASS_ASSUMED + + def __init__(self): + """Initialize the EnOcean config flow.""" + self.dongle_path = None + self.discovery_info = None + + async def async_step_import(self, data=None): + """Import a yaml configuration.""" + + if not await self.validate_enocean_conf(data): + LOGGER.warning( + "Cannot import yaml configuration: %s is not a valid dongle path", + data[CONF_DEVICE], + ) + return self.async_abort(reason="invalid_dongle_path") + + return self.create_enocean_entry(data) + + async def async_step_user(self, user_input=None): + """Handle an EnOcean config flow start.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + return await self.async_step_detect() + + async def async_step_detect(self, user_input=None): + """Propose a list of detected dongles.""" + errors = {} + if user_input is not None: + if user_input[CONF_DEVICE] == self.MANUAL_PATH_VALUE: + return await self.async_step_manual(None) + if await self.validate_enocean_conf(user_input): + return self.create_enocean_entry(user_input) + errors = {CONF_DEVICE: ERROR_INVALID_DONGLE_PATH} + + bridges = await self.hass.async_add_executor_job(dongle.detect) + if len(bridges) == 0: + return await self.async_step_manual(user_input) + + bridges.append(self.MANUAL_PATH_VALUE) + return self.async_show_form( + step_id="detect", + data_schema=vol.Schema({vol.Required(CONF_DEVICE): vol.In(bridges)}), + errors=errors, + ) + + async def async_step_manual(self, user_input=None): + """Request manual USB dongle path.""" + default_value = None + errors = {} + if user_input is not None: + if await self.validate_enocean_conf(user_input): + return self.create_enocean_entry(user_input) + default_value = user_input[CONF_DEVICE] + errors = {CONF_DEVICE: ERROR_INVALID_DONGLE_PATH} + + return self.async_show_form( + step_id="manual", + data_schema=vol.Schema( + {vol.Required(CONF_DEVICE, default=default_value): str} + ), + errors=errors, + ) + + async def validate_enocean_conf(self, user_input) -> bool: + """Return True if the user_input contains a valid dongle path.""" + dongle_path = user_input[CONF_DEVICE] + path_is_valid = await self.hass.async_add_executor_job( + dongle.validate_path, dongle_path + ) + return path_is_valid + + def create_enocean_entry(self, user_input): + """Create an entry for the provided configuration.""" + return self.async_create_entry(title="EnOcean", data=user_input) diff --git a/homeassistant/components/enocean/const.py b/homeassistant/components/enocean/const.py new file mode 100644 index 0000000000000000000000000000000000000000..a020a74513784147d91e28813f859749a1c4d96b --- /dev/null +++ b/homeassistant/components/enocean/const.py @@ -0,0 +1,15 @@ +"""Constants for the ENOcean integration.""" +import logging + +DOMAIN = "enocean" +DATA_ENOCEAN = "enocean" +ENOCEAN_DONGLE = "dongle" + +ERROR_INVALID_DONGLE_PATH = "invalid_dongle_path" + +SIGNAL_RECEIVE_MESSAGE = "enocean.receive_message" +SIGNAL_SEND_MESSAGE = "enocean.send_message" + +LOGGER = logging.getLogger(__package__) + +PLATFORMS = ["light", "binary_sensor", "sensor", "switch"] diff --git a/homeassistant/components/enocean/device.py b/homeassistant/components/enocean/device.py new file mode 100644 index 0000000000000000000000000000000000000000..36477d21cffaab63195ebc43b1038259c64bb08c --- /dev/null +++ b/homeassistant/components/enocean/device.py @@ -0,0 +1,39 @@ +"""Representation of an EnOcean device.""" +from enocean.protocol.packet import Packet +from enocean.utils import combine_hex + +from homeassistant.helpers.entity import Entity + +from .const import SIGNAL_RECEIVE_MESSAGE, SIGNAL_SEND_MESSAGE + + +class EnOceanEntity(Entity): + """Parent class for all entities associated with the EnOcean component.""" + + def __init__(self, dev_id, dev_name="EnOcean device"): + """Initialize the device.""" + self.dev_id = dev_id + self.dev_name = dev_name + + async def async_added_to_hass(self): + """Register callbacks.""" + self.async_on_remove( + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_RECEIVE_MESSAGE, self._message_received_callback + ) + ) + + def _message_received_callback(self, packet): + """Handle incoming packets.""" + + if packet.sender_int == combine_hex(self.dev_id): + self.value_changed(packet) + + def value_changed(self, packet): + """Update the internal state of the device when a packet arrives.""" + + def send_command(self, data, optional, packet_type): + """Send a command via the EnOcean dongle.""" + + packet = Packet(packet_type, data=data, optional=optional) + self.hass.helpers.dispatcher.dispatcher_send(SIGNAL_SEND_MESSAGE, packet) diff --git a/homeassistant/components/enocean/dongle.py b/homeassistant/components/enocean/dongle.py new file mode 100644 index 0000000000000000000000000000000000000000..63ab3e8692563199ea46215fca92a5b041dc7239 --- /dev/null +++ b/homeassistant/components/enocean/dongle.py @@ -0,0 +1,87 @@ +"""Representation of an EnOcean dongle.""" +import glob +import logging +from os.path import basename, normpath + +from enocean.communicators import SerialCommunicator +from enocean.protocol.packet import RadioPacket +import serial + +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import SIGNAL_RECEIVE_MESSAGE, SIGNAL_SEND_MESSAGE + +_LOGGER = logging.getLogger(__name__) + + +class EnOceanDongle: + """Representation of an EnOcean dongle. + + The dongle is responsible for receiving the ENOcean frames, + creating devices if needed, and dispatching messages to platforms. + """ + + def __init__(self, hass, serial_path): + """Initialize the EnOcean dongle.""" + + self._communicator = SerialCommunicator( + port=serial_path, callback=self.callback + ) + self.serial_path = serial_path + self.identifier = basename(normpath(serial_path)) + self.hass = hass + self.dispatcher_disconnect_handle = None + + async def async_setup(self): + """Finish the setup of the bridge and supported platforms.""" + self._communicator.start() + self.dispatcher_disconnect_handle = async_dispatcher_connect( + self.hass, SIGNAL_SEND_MESSAGE, self._send_message_callback + ) + + def unload(self): + """Disconnect callbacks established at init time.""" + if self.dispatcher_disconnect_handle: + self.dispatcher_disconnect_handle() + self.dispatcher_disconnect_handle = None + + def _send_message_callback(self, command): + """Send a command through the EnOcean dongle.""" + self._communicator.send(command) + + def callback(self, packet): + """Handle EnOcean device's callback. + + This is the callback function called by python-enocan whenever there + is an incoming packet. + """ + + if isinstance(packet, RadioPacket): + _LOGGER.debug("Received radio packet: %s", packet) + self.hass.helpers.dispatcher.dispatcher_send(SIGNAL_RECEIVE_MESSAGE, packet) + + +def detect(): + """Return a list of candidate paths for USB ENOcean dongles. + + This method is currently a bit simplistic, it may need to be + improved to support more configurations and OS. + """ + globs_to_test = ["/dev/tty*FTOA2PV*", "/dev/serial/by-id/*EnOcean*"] + found_paths = [] + for current_glob in globs_to_test: + found_paths.extend(glob.glob(current_glob)) + + return found_paths + + +def validate_path(path: str): + """Return True if the provided path points to a valid serial port, False otherwise.""" + try: + # Creating the serial communicator will raise an exception + # if it cannot connect + SerialCommunicator(port=path) + return True + except serial.SerialException as exception: + _LOGGER.warning("Dongle path %s is invalid: %s", path, str(exception)) + return False diff --git a/homeassistant/components/enocean/light.py b/homeassistant/components/enocean/light.py index 0df0c94775a6c938045d964e23743a5d5394108d..04b234425c12f83253432a10fc73ba7b2ee70164 100644 --- a/homeassistant/components/enocean/light.py +++ b/homeassistant/components/enocean/light.py @@ -4,7 +4,6 @@ import math import voluptuous as vol -from homeassistant.components import enocean from homeassistant.components.light import ( ATTR_BRIGHTNESS, PLATFORM_SCHEMA, @@ -14,6 +13,8 @@ from homeassistant.components.light import ( from homeassistant.const import CONF_ID, CONF_NAME import homeassistant.helpers.config_validation as cv +from .device import EnOceanEntity + _LOGGER = logging.getLogger(__name__) CONF_SENDER_ID = "sender_id" @@ -39,7 +40,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities([EnOceanLight(sender_id, dev_id, dev_name)]) -class EnOceanLight(enocean.EnOceanDevice, LightEntity): +class EnOceanLight(EnOceanEntity, LightEntity): """Representation of an EnOcean light source.""" def __init__(self, sender_id, dev_id, dev_name): diff --git a/homeassistant/components/enocean/manifest.json b/homeassistant/components/enocean/manifest.json index a02661f8883eb449e080bd40d1c66c9ee3bef7c2..390b48342fde8d13c4f7afb981c91be295696396 100644 --- a/homeassistant/components/enocean/manifest.json +++ b/homeassistant/components/enocean/manifest.json @@ -2,6 +2,11 @@ "domain": "enocean", "name": "EnOcean", "documentation": "https://www.home-assistant.io/integrations/enocean", - "requirements": ["enocean==0.50"], - "codeowners": ["@bdurrer"] + "requirements": [ + "enocean==0.50" + ], + "codeowners": [ + "@bdurrer" + ], + "config_flow": true } diff --git a/homeassistant/components/enocean/sensor.py b/homeassistant/components/enocean/sensor.py index 16f6238acdc45de47114db65a74b685038820fec..07d06824365aeda7ebb5ba89f8e1135b3d796de5 100644 --- a/homeassistant/components/enocean/sensor.py +++ b/homeassistant/components/enocean/sensor.py @@ -3,7 +3,6 @@ import logging import voluptuous as vol -from homeassistant.components import enocean from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( CONF_DEVICE_CLASS, @@ -21,6 +20,8 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.helpers.restore_state import RestoreEntity +from .device import EnOceanEntity + _LOGGER = logging.getLogger(__name__) CONF_MAX_TEMP = "max_temp" @@ -62,7 +63,6 @@ SENSOR_TYPES = { }, } - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_ID): vol.All(cv.ensure_list, [vol.Coerce(int)]), @@ -105,7 +105,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities([EnOceanWindowHandle(dev_id, dev_name)]) -class EnOceanSensor(enocean.EnOceanDevice, RestoreEntity): +class EnOceanSensor(EnOceanEntity, RestoreEntity): """Representation of an EnOcean sensor device such as a power meter.""" def __init__(self, dev_id, dev_name, sensor_type): diff --git a/homeassistant/components/enocean/strings.json b/homeassistant/components/enocean/strings.json new file mode 100644 index 0000000000000000000000000000000000000000..633c97e51af4a4ff537d10fb44b5bf6a7f718402 --- /dev/null +++ b/homeassistant/components/enocean/strings.json @@ -0,0 +1,27 @@ +{ + "title": "EnOcean", + "config": { + "flow_title": "ENOcean setup", + "step": { + "detect": { + "title": "Select the path to you ENOcean dongle", + "data": { + "path": "USB dongle path" + } + }, + "manual": { + "title": "Enter the path to you ENOcean dongle", + "data": { + "path": "USB dongle path" + } + } + }, + "error": { + "invalid_dongle_path": "No valid dongle found for this path" + }, + "abort": { + "invalid_dongle_path": "Invalid dongle path", + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + } + } +} diff --git a/homeassistant/components/enocean/switch.py b/homeassistant/components/enocean/switch.py index 92642e329d93d6518ebf7422572444ac8dd981ff..6ce5fbd31806994676aad9e66e66839a75b00370 100644 --- a/homeassistant/components/enocean/switch.py +++ b/homeassistant/components/enocean/switch.py @@ -3,12 +3,13 @@ import logging import voluptuous as vol -from homeassistant.components import enocean from homeassistant.components.switch import PLATFORM_SCHEMA from homeassistant.const import CONF_ID, CONF_NAME import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import ToggleEntity +from .device import EnOceanEntity + _LOGGER = logging.getLogger(__name__) CONF_CHANNEL = "channel" @@ -32,7 +33,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities([EnOceanSwitch(dev_id, dev_name, channel)]) -class EnOceanSwitch(enocean.EnOceanDevice, ToggleEntity): +class EnOceanSwitch(EnOceanEntity, ToggleEntity): """Representation of an EnOcean switch device.""" def __init__(self, dev_id, dev_name, channel): diff --git a/homeassistant/components/enocean/translations/en.json b/homeassistant/components/enocean/translations/en.json new file mode 100644 index 0000000000000000000000000000000000000000..7715775335ec7098133c379d4c535d0a3078fe05 --- /dev/null +++ b/homeassistant/components/enocean/translations/en.json @@ -0,0 +1,27 @@ +{ + "title": "EnOcean", + "config": { + "flow_title": "ENOcean setup", + "step": { + "detect": { + "title": "Select the path to you ENOcean dongle", + "data": { + "path": "USB dongle path" + } + }, + "manual": { + "title": "Enter the path to you ENOcean dongle", + "data": { + "path": "USB dongle path" + } + } + }, + "error": { + "invalid_dongle_path": "No valid dongle found for this path" + }, + "abort": { + "invalid_dongle_path": "Invalid dongle path", + "single_instance_allowed": "An instance is already configured" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 9e2386d6bbaff98645d9348d260b0b5d3cdebace..23fdc656af6176038456c503528776228940b853 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -45,6 +45,7 @@ FLOWS = [ "elgato", "elkm1", "emulated_roku", + "enocean", "esphome", "flick_electric", "flume", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0ecec6a12b70d0733b954764f9296758468b40e6..6f6be87b793c4ed7e6255421625ed5ec5a4e11e4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -265,6 +265,9 @@ emoji==0.5.4 # homeassistant.components.emulated_roku emulated_roku==0.2.1 +# homeassistant.components.enocean +enocean==0.50 + # homeassistant.components.season ephem==3.7.7.0 diff --git a/tests/components/enocean/__init__.py b/tests/components/enocean/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..81771694abf8bc7804c3318458903b84958da2b1 --- /dev/null +++ b/tests/components/enocean/__init__.py @@ -0,0 +1 @@ +"""Tests of the EnOcean integration.""" diff --git a/tests/components/enocean/test_config_flow.py b/tests/components/enocean/test_config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..aee6765272e302f6d3d9fe9001a62b5d136a51c2 --- /dev/null +++ b/tests/components/enocean/test_config_flow.py @@ -0,0 +1,159 @@ +"""Tests for EnOcean config flow.""" +from homeassistant import data_entry_flow +from homeassistant.components.enocean.config_flow import EnOceanFlowHandler +from homeassistant.components.enocean.const import DOMAIN +from homeassistant.const import CONF_DEVICE + +from tests.async_mock import Mock, patch +from tests.common import MockConfigEntry + +DONGLE_VALIDATE_PATH_METHOD = "homeassistant.components.enocean.dongle.validate_path" +DONGLE_DETECT_METHOD = "homeassistant.components.enocean.dongle.detect" + + +async def test_user_flow_cannot_create_multiple_instances(hass): + """Test that the user flow aborts if an instance is already configured.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_DEVICE: "/already/configured/path"} + ) + entry.add_to_hass(hass) + + with patch(DONGLE_VALIDATE_PATH_METHOD, Mock(return_value=True)): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "single_instance_allowed" + + +async def test_user_flow_with_detected_dongle(hass): + """Test the user flow with a detected ENOcean dongle.""" + FAKE_DONGLE_PATH = "/fake/dongle" + + with patch(DONGLE_DETECT_METHOD, Mock(return_value=[FAKE_DONGLE_PATH])): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "detect" + devices = result["data_schema"].schema.get("device").container + assert FAKE_DONGLE_PATH in devices + assert EnOceanFlowHandler.MANUAL_PATH_VALUE in devices + + +async def test_user_flow_with_no_detected_dongle(hass): + """Test the user flow with a detected ENOcean dongle.""" + with patch(DONGLE_DETECT_METHOD, Mock(return_value=[])): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "manual" + + +async def test_detection_flow_with_valid_path(hass): + """Test the detection flow with a valid path selected.""" + USER_PROVIDED_PATH = "/user/provided/path" + + with patch(DONGLE_VALIDATE_PATH_METHOD, Mock(return_value=True)): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "detect"}, data={CONF_DEVICE: USER_PROVIDED_PATH} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"][CONF_DEVICE] == USER_PROVIDED_PATH + + +async def test_detection_flow_with_custom_path(hass): + """Test the detection flow with custom path selected.""" + USER_PROVIDED_PATH = EnOceanFlowHandler.MANUAL_PATH_VALUE + FAKE_DONGLE_PATH = "/fake/dongle" + + with patch(DONGLE_VALIDATE_PATH_METHOD, Mock(return_value=True)): + with patch(DONGLE_DETECT_METHOD, Mock(return_value=[FAKE_DONGLE_PATH])): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "detect"}, + data={CONF_DEVICE: USER_PROVIDED_PATH}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "manual" + + +async def test_detection_flow_with_invalid_path(hass): + """Test the detection flow with an invalid path selected.""" + USER_PROVIDED_PATH = "/invalid/path" + FAKE_DONGLE_PATH = "/fake/dongle" + + with patch(DONGLE_VALIDATE_PATH_METHOD, Mock(return_value=False)): + with patch(DONGLE_DETECT_METHOD, Mock(return_value=[FAKE_DONGLE_PATH])): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "detect"}, + data={CONF_DEVICE: USER_PROVIDED_PATH}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "detect" + assert CONF_DEVICE in result["errors"] + + +async def test_manual_flow_with_valid_path(hass): + """Test the manual flow with a valid path.""" + USER_PROVIDED_PATH = "/user/provided/path" + + with patch(DONGLE_VALIDATE_PATH_METHOD, Mock(return_value=True)): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "manual"}, data={CONF_DEVICE: USER_PROVIDED_PATH} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"][CONF_DEVICE] == USER_PROVIDED_PATH + + +async def test_manual_flow_with_invalid_path(hass): + """Test the manual flow with an invalid path.""" + USER_PROVIDED_PATH = "/user/provided/path" + + with patch( + DONGLE_VALIDATE_PATH_METHOD, Mock(return_value=False), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "manual"}, data={CONF_DEVICE: USER_PROVIDED_PATH} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "manual" + assert CONF_DEVICE in result["errors"] + + +async def test_import_flow_with_valid_path(hass): + """Test the import flow with a valid path.""" + DATA_TO_IMPORT = {CONF_DEVICE: "/valid/path/to/import"} + + with patch(DONGLE_VALIDATE_PATH_METHOD, Mock(return_value=True)): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "import"}, data=DATA_TO_IMPORT + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"][CONF_DEVICE] == DATA_TO_IMPORT[CONF_DEVICE] + + +async def test_import_flow_with_invalid_path(hass): + """Test the import flow with an invalid path.""" + DATA_TO_IMPORT = {CONF_DEVICE: "/invalid/path/to/import"} + + with patch( + DONGLE_VALIDATE_PATH_METHOD, Mock(return_value=False), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "import"}, data=DATA_TO_IMPORT + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "invalid_dongle_path"