diff --git a/CODEOWNERS b/CODEOWNERS index e510eec6dfa046..67c9fbcc4e32b2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -592,6 +592,8 @@ build.json @home-assistant/supervisor /tests/components/greeneye_monitor/ @jkeljo /homeassistant/components/group/ @home-assistant/core /tests/components/group/ @home-assistant/core +/homeassistant/components/gryfsmart/ @karlowiczpl +/tests/components/gryfsmart/ @karlowiczpl /homeassistant/components/guardian/ @bachya /tests/components/guardian/ @bachya /homeassistant/components/habitica/ @tr4nt0r diff --git a/homeassistant/components/gryfsmart/__init__.py b/homeassistant/components/gryfsmart/__init__.py new file mode 100644 index 00000000000000..83094aa4a7ae9e --- /dev/null +++ b/homeassistant/components/gryfsmart/__init__.py @@ -0,0 +1,90 @@ +"""The Gryf Smart integration.""" + +from __future__ import annotations + +import logging + +from pygryfsmart.api import GryfApi + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.typing import ConfigType + +from .const import CONF_API, CONF_COMMUNICATION, CONF_DEVICE_DATA, CONF_PORT, DOMAIN +from .schema import CONFIG_SCHEMA as SCHEMA + +CONFIG_SCHEMA = SCHEMA + +_PLATFORMS: list[Platform] = [ + Platform.LIGHT, + # Platform.BINARY_SENSOR, + # Platform.SENSOR, + # Platform.CLIMATE, + # Platform.SWITCH, +] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup( + hass: HomeAssistant, + config: ConfigType, +) -> bool: + """Set up the Gryf Smart Integration.""" + + if config.get(DOMAIN) is None: + return True + + try: + api = GryfApi(config[DOMAIN][CONF_PORT]) + await api.start_connection() + api.start_update_interval(1) + except ConnectionError: + _LOGGER.error("Unable to connect: %s", ConnectionError) + return False + + hass.data[DOMAIN] = config.get(DOMAIN) + hass.data[DOMAIN][CONF_API] = api + + for PLATFORM in _PLATFORMS: + await async_load_platform(hass, PLATFORM, DOMAIN, None, config) + + return True + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, +) -> bool: + """Config flow for Gryf Smart Integration.""" + + try: + api = GryfApi(entry.data[CONF_COMMUNICATION][CONF_PORT]) + await api.start_connection() + api.start_update_interval(1) + except ConnectionError: + raise ConfigEntryNotReady("Unable to connect with device") from ConnectionError + + entry.runtime_data = {} + entry.runtime_data[CONF_API] = api + entry.runtime_data[CONF_DEVICE_DATA] = { + "identifiers": {(DOMAIN, "Gryf Smart", entry.unique_id)}, + "name": f"Gryf Smart {entry.unique_id}", + "manufacturer": "Gryf Smart", + "model": "serial", + "sw_version": "1.0.0", + "hw_version": "1.0.0", + } + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/gryfsmart/config_flow.py b/homeassistant/components/gryfsmart/config_flow.py new file mode 100644 index 00000000000000..1ef3f450416c23 --- /dev/null +++ b/homeassistant/components/gryfsmart/config_flow.py @@ -0,0 +1,456 @@ +"""Handle the configuration flow for the Gryf Smart integration.""" + +import logging +from types import MappingProxyType +from typing import Any +import uuid + +from pygryfsmart.rs232 import RS232Handler +from serial import SerialException +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import Platform +from homeassistant.core import callback +from homeassistant.helpers import selector + +from .const import ( + BINARY_SENSOR_DEVICE_CLASS, + CONF_COMMUNICATION, + CONF_DEVICES, + CONF_EXTRA, + CONF_ID, + CONF_MODULE_COUNT, + CONF_NAME, + CONF_PORT, + CONF_TYPE, + CONFIG_FLOW_MENU_OPTIONS, + DEFAULT_PORT, + DEVICE_TYPES, + DOMAIN, + SWITCH_DEVICE_CLASS, +) + +_LOGGER = logging.getLogger(__name__) + + +async def ping_connection(port) -> bool: + """Test connection.""" + writer = RS232Handler(port, 115200) + try: + await writer.open_connection() + await writer.close_connection() + return True + except SerialException as e: + _LOGGER.error("%s", e) + return False + else: + return True + + +class GryfSmartConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Gryf Smart ConfigFlow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + def __init__(self) -> None: + """Initialize Gryf Smart ConfigFlow.""" + super().__init__() + self._config_data: dict[str, Any] = {} + self._config_data[CONF_DEVICES] = [] + self._current_device: dict[str, Any] + self._edit_index: int | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """First config flow step, selecting communication parameters.""" + + errors = {} + + if user_input: + if not await ping_connection(user_input.get(CONF_PORT)): + errors[CONF_PORT] = "Unable to connect" + elif not user_input.get(CONF_PORT, "").startswith("/dev/"): + errors[CONF_PORT] = "invalid_port" + else: + self._config_data = { + CONF_COMMUNICATION: {}, + CONF_DEVICES: [], + } + + self._config_data[CONF_COMMUNICATION][CONF_PORT] = user_input[CONF_PORT] + self._config_data[CONF_COMMUNICATION][CONF_MODULE_COUNT] = user_input[ + CONF_MODULE_COUNT + ] + + return await self.async_step_device_menu() + + await self.async_set_unique_id(str(uuid.uuid4())[:8]) + self._abort_if_unique_id_configured() + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_PORT, default=DEFAULT_PORT): str, + vol.Required(CONF_MODULE_COUNT, default=1): int, + } + ), + errors=errors, + ) + + async def async_step_device_menu( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Show menu step.""" + + return self.async_show_menu( + step_id="device_menu", + menu_options=CONFIG_FLOW_MENU_OPTIONS, + ) + + async def async_step_add_device( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Add new device.""" + + errors = {} + if user_input: + new_device = { + CONF_TYPE: user_input[CONF_TYPE], + CONF_NAME: user_input[CONF_NAME], + CONF_ID: user_input[CONF_ID], + CONF_EXTRA: user_input.get(CONF_EXTRA), + } + self._config_data[CONF_DEVICES].append(new_device) + + if ( + check_extra_parameter( + user_input.get(CONF_EXTRA), user_input.get(CONF_TYPE) + ) + is None + ): + return await self.async_step_device_menu() + errors[CONF_TYPE] = "Bad binary sensor extra parameter!" + + return self.async_show_form( + step_id="add_device", + data_schema=vol.Schema( + { + vol.Required(CONF_TYPE): vol.In(DEVICE_TYPES), + vol.Required(CONF_NAME): str, + vol.Required(CONF_ID): int, + vol.Optional(CONF_EXTRA): str, + } + ), + errors=errors, + ) + + async def async_step_edit_device( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Select device to edit.""" + if not self._config_data[CONF_DEVICES]: + return await self.async_step_device_menu() + + if user_input: + self._edit_index = int(user_input["device_index"]) + self._current_device = self._config_data[CONF_DEVICES][ + self._edit_index + ].copy() + return await self.async_step_edit_device_details() + + devices = [ + selector.SelectOptionDict( + value=str(idx), label=f"{dev[CONF_NAME]} (ID: {dev[CONF_ID]})" + ) + for idx, dev in enumerate(self._config_data[CONF_DEVICES]) + ] + + return self.async_show_form( + step_id="edit_device", + data_schema=vol.Schema( + { + vol.Required("device_index"): selector.SelectSelector( + selector.SelectSelectorConfig(options=devices) + ) + } + ), + ) + + async def async_step_edit_device_details( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Edit device parameters.""" + + errors = {} + + if user_input: + if ( + check_extra_parameter( + user_input.get(CONF_EXTRA), user_input.get(CONF_TYPE) + ) + is None + ): + self._config_data[CONF_DEVICES][self._edit_index] = user_input + self._config_data[CONF_DEVICES][self._edit_index][CONF_EXTRA] = ( + user_input.get(CONF_EXTRA) + ) + self._edit_index = None + return await self.async_step_device_menu() + errors[CONF_TYPE] = "Bad binary sensor extra parameter!" + + return self.async_show_form( + step_id="edit_device_details", + data_schema=vol.Schema( + { + vol.Required( + CONF_TYPE, default=self._current_device[CONF_TYPE] + ): vol.In(DEVICE_TYPES), + vol.Required( + CONF_NAME, default=self._current_device[CONF_NAME] + ): str, + vol.Required(CONF_ID, default=self._current_device[CONF_ID]): int, + vol.Optional( + CONF_EXTRA, default=self._current_device.get(CONF_EXTRA) + ): str, + } + ), + errors=errors, + ) + + async def async_step_finish( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Finish the config flow.""" + return self.async_create_entry( + title=f"GryfSmart: {self._config_data[CONF_COMMUNICATION][CONF_PORT]}", + data=self._config_data, + ) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: + """Get the options flow for this handler.""" + + return GryfSmartOptionsFlow() + + +class GryfSmartOptionsFlow(config_entries.OptionsFlow): + """Handle the options flow for the Gryf Smart Integration.""" + + _edit_index: int + data: MappingProxyType[str, Any] + _current_device: dict + + def __init__(self) -> None: + """Initialize OptionsFlow.""" + self._edit_index = 0 + self._current_device = {} + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Initialize the Gryf Smart options flow.""" + self.data = self.config_entry.data + _LOGGER.debug("%s", self.data) + return await self.async_step_main_menu() + + async def async_step_main_menu( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Show main options menu.""" + return self.async_show_menu( + step_id="main_menu", menu_options=CONFIG_FLOW_MENU_OPTIONS + ) + + async def async_step_add_device( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Add new device.""" + + errors = {} + + if user_input: + new_device = { + CONF_TYPE: user_input[CONF_TYPE], + CONF_NAME: user_input[CONF_NAME], + CONF_ID: user_input[CONF_ID], + CONF_EXTRA: user_input.get(CONF_EXTRA), + } + if ( + check_extra_parameter( + user_input.get(CONF_EXTRA), user_input.get(CONF_TYPE) + ) + is None + ): + self.data["devices"].append(new_device) + return await self.async_step_main_menu() + errors[CONF_TYPE] = "Bad binary sensor extra parameter!" + + return self.async_show_form( + step_id="add_device", + data_schema=vol.Schema( + { + vol.Required(CONF_TYPE): vol.In(DEVICE_TYPES), + vol.Required(CONF_NAME): str, + vol.Required(CONF_ID): int, + vol.Optional(CONF_EXTRA): str, + } + ), + errors=errors, + ) + + async def async_step_edit_device( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Select device to edit.""" + if not self.data[CONF_DEVICES]: + return await self.async_step_main_menu() + + if user_input: + self._edit_index = int(user_input["device_index"]) + self._current_device = self.data[CONF_DEVICES][self._edit_index].copy() + return await self.async_step_edit_device_details() + + devices = [ + selector.SelectOptionDict( + value=str(idx), label=f"{dev[CONF_NAME]} (ID: {dev[CONF_ID]})" + ) + for idx, dev in enumerate(self.data[CONF_DEVICES]) + ] + + return self.async_show_form( + step_id="edit_device", + data_schema=vol.Schema( + { + vol.Required("device_index"): selector.SelectSelector( + selector.SelectSelectorConfig(options=devices) + ) + } + ), + ) + + async def async_step_edit_device_details( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Edit device parameters.""" + + errors = {} + + if user_input: + if ( + check_extra_parameter( + user_input.get(CONF_EXTRA), user_input.get(CONF_TYPE) + ) + is None + ): + self.data[CONF_DEVICES][self._edit_index] = user_input + _LOGGER.debug("%s", user_input) + self._edit_index = 0 + return await self.async_step_main_menu() + errors[CONF_TYPE] = "Bad binary sensor extra parameter!" + + if self._current_device.get(CONF_EXTRA) is not None: + return self.async_show_form( + step_id="edit_device_details", + data_schema=vol.Schema( + { + vol.Required( + CONF_TYPE, default=self._current_device[CONF_TYPE] + ): vol.In(DEVICE_TYPES), + vol.Required( + CONF_NAME, default=self._current_device[CONF_NAME] + ): str, + vol.Required( + CONF_ID, default=self._current_device[CONF_ID] + ): int, + vol.Optional( + CONF_EXTRA, default=self._current_device.get(CONF_EXTRA) + ): str, + } + ), + errors=errors, + ) + return self.async_show_form( + step_id="edit_device_details", + data_schema=vol.Schema( + { + vol.Required( + CONF_TYPE, default=self._current_device[CONF_TYPE] + ): vol.In(DEVICE_TYPES), + vol.Required( + CONF_NAME, default=self._current_device[CONF_NAME] + ): str, + vol.Required(CONF_ID, default=self._current_device[CONF_ID]): int, + vol.Optional(CONF_EXTRA): str, + } + ), + errors=errors, + ) + + async def async_step_finish( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Finish config flow.""" + return self.async_create_entry(data=self.data) + + async def async_step_communication( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Show communication form.""" + errors = {} + if user_input: + if not await ping_connection(user_input.get(CONF_PORT)): + errors[CONF_PORT] = "Unable to connect" + elif not user_input.get(CONF_PORT, "").startswith("/dev/"): + errors[CONF_PORT] = "invalid_port" + else: + self.data[CONF_COMMUNICATION][CONF_PORT] = user_input[CONF_PORT] + self.data[CONF_COMMUNICATION][CONF_MODULE_COUNT] = user_input[ + CONF_MODULE_COUNT + ] + + return await self.async_step_main_menu() + + return self.async_show_form( + step_id="communication", + data_schema=vol.Schema( + { + vol.Required( + CONF_PORT, default=self.data[CONF_COMMUNICATION][CONF_PORT] + ): str, + vol.Required( + CONF_MODULE_COUNT, + default=self.data[CONF_COMMUNICATION][CONF_MODULE_COUNT], + ): int, + } + ), + errors=errors, + ) + + +def check_extra_parameter( + extra_parameter: Any, + device_type: Any | None, +) -> str | None: + """Check extra parameter.""" + + if device_type == Platform.BINARY_SENSOR: + if extra_parameter not in BINARY_SENSOR_DEVICE_CLASS: + return "Bad binary sensor extra parameter!" + elif device_type == Platform.SWITCH: + if extra_parameter not in SWITCH_DEVICE_CLASS: + return "Bad Output extra parameter!" + elif device_type == Platform.CLIMATE: + try: + if int(extra_parameter) > 10: + return None + except ValueError: + return "Bad Thermostate extra parameter!" + + return None diff --git a/homeassistant/components/gryfsmart/const.py b/homeassistant/components/gryfsmart/const.py new file mode 100755 index 00000000000000..bc93a826ff054c --- /dev/null +++ b/homeassistant/components/gryfsmart/const.py @@ -0,0 +1,78 @@ +"""Define constants used throughout the Gryf Smart integration.""" + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.switch import SwitchDeviceClass +from homeassistant.const import Platform + +DOMAIN = "gryfsmart" + +CONF_MODULE_COUNT = "module_count" +CONF_PORT = "port" +CONF_TYPE = "type" +CONF_ID = "id" +CONF_PIN = "pin" +CONF_NAME = "name" +CONF_EXTRA = "extra parameters" +CONF_DEVICES = "devices" +CONF_RECONFIGURE = "reconfigure" +CONF_COMMUNICATION = "communication" +CONF_API = "api" +CONF_DEVICE_DATA = "device_data" +CONF_INPUTS = "input" +CONF_DEVICE_CLASS = "device_class" +CONF_OUT_ID = "o_id" +CONF_TEMP_ID = "t_id" +CONF_HYSTERESIS = "hysteresis" + +PLATFORM_PWM = "pwm" +PLATFORM_TEMPERATURE = "temperature" +PLATFORM_INPUT = "input" +PLATFORM_LIGHT = "light" +PLATFORM_BINARY_SENSOR = "binary_input" +PLATFORM_SWITCH = "output" +PLATFORM_THERMOSTAT = "thermostat" + +DEFAULT_PORT = "/dev/ttyUSB0" +GRYF_IN_NAME = "Gryf IN" +GRYF_OUT_NAME = "Gryf OUT" + +CONFIG_FLOW_MENU_OPTIONS = { + "add_device": "Add Device", + "edit_device": "Edit Device", + "communication": "Setup Communication", + "finish": "Finish", +} + +DEVICE_TYPES = { + Platform.LIGHT: "Lights", + # Platform.SWITCH: "Output", + # Platform.BINARY_SENSOR: "Binary input", + # Platform.CLIMATE: "Thermostat", + # PLATFORM_PWM: "PWM", + # PLATFORM_TEMPERATURE: "Termometr", + PLATFORM_INPUT: "Input", +} + +CONF_LINE_SENSOR_ICONS = { + GRYF_IN_NAME: ["mdi:message-arrow-right-outline", "mdi:message-arrow-right"], + GRYF_OUT_NAME: ["mdi:message-arrow-left-outline", "mdi:message-arrow-left"], +} + +BINARY_SENSOR_DEVICE_CLASS = { + "door": BinarySensorDeviceClass.DOOR, + "garage door": BinarySensorDeviceClass.GARAGE_DOOR, + "heat": BinarySensorDeviceClass.HEAT, + "light": BinarySensorDeviceClass.LIGHT, + "motion": BinarySensorDeviceClass.MOTION, + "window": BinarySensorDeviceClass.WINDOW, + "smoke": BinarySensorDeviceClass.SMOKE, + "sound": BinarySensorDeviceClass.SOUND, + "power": BinarySensorDeviceClass.POWER, + None: BinarySensorDeviceClass.OPENING, +} + +SWITCH_DEVICE_CLASS = { + None: SwitchDeviceClass.SWITCH, + "switch": SwitchDeviceClass.SWITCH, + "outlet": SwitchDeviceClass.OUTLET, +} diff --git a/homeassistant/components/gryfsmart/entity.py b/homeassistant/components/gryfsmart/entity.py new file mode 100755 index 00000000000000..d45ccb0de3da50 --- /dev/null +++ b/homeassistant/components/gryfsmart/entity.py @@ -0,0 +1,73 @@ +"""Define the base entity for the Gryf Smart integration.""" + +from __future__ import annotations + +from pygryfsmart.api import GryfApi +from pygryfsmart.device import _GryfDevice + +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import CONF_DEVICE_DATA + + +class _GryfSmartEntityBase(Entity): + """Base Entity for Gryf Smart.""" + + _attr_should_poll = False + _attr_entity_registry_enabled_default = True + + _api: GryfApi + _device: _GryfDevice + _attr_unique_id: str | None + + @property + def name(self) -> str: + return self._device.name + + +class GryfConfigFlowEntity(_GryfSmartEntityBase): + """Gryf Config flow entity class.""" + + _attr_has_entity_name = True + _device: _GryfDevice + _config_entry: ConfigEntry + + def __init__( + self, + config_entry: ConfigEntry, + device: _GryfDevice, + ) -> None: + """Init Gryf config flow entity.""" + + self._device = device + self._config_entry = config_entry + super().__init__() + + @property + def device_info(self) -> DeviceInfo | None: + """Return device info.""" + return self._config_entry.runtime_data[CONF_DEVICE_DATA] + + @property + def unique_id(self) -> str | None: + """Return unique_id.""" + return f"{self._device.name} {self._config_entry.unique_id}" + + +class GryfYamlEntity(_GryfSmartEntityBase): + """Gryf yaml entity class.""" + + _attr_has_entity_name = True + _device: _GryfDevice + + def __init__(self, device: _GryfDevice) -> None: + """Init Gryf yaml entity.""" + super().__init__() + self._device = device + + @property + def unique_id(self) -> str | None: + """Return unique id.""" + return self._device.name diff --git a/homeassistant/components/gryfsmart/light.py b/homeassistant/components/gryfsmart/light.py new file mode 100755 index 00000000000000..7569c8b662e1b4 --- /dev/null +++ b/homeassistant/components/gryfsmart/light.py @@ -0,0 +1,202 @@ +"""Handle the Gryf Smart light platform functionality.""" + +from typing import Any + +from pygryfsmart.device import _GryfDevice, _GryfOutput, _GryfPwm + +from homeassistant.components.light import ColorMode, LightEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TYPE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from .const import CONF_API, CONF_DEVICES, CONF_ID, CONF_NAME, DOMAIN, PLATFORM_PWM +from .entity import GryfConfigFlowEntity, GryfYamlEntity + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None, +) -> None: + """Set up the Light platform.""" + + lights = [] + pwm = [] + + for conf in hass.data[DOMAIN].get(Platform.LIGHT, {}): + device = _GryfOutput( + conf.get(CONF_NAME), + conf.get(CONF_ID) // 10, + conf.get(CONF_ID) % 10, + hass.data[DOMAIN][CONF_API], + ) + lights.append(GryfYamlLight(device)) + + for conf in hass.data[DOMAIN].get(PLATFORM_PWM, {}): + device = _GryfPwm( + conf.get(CONF_NAME), + conf.get(CONF_ID) // 10, + conf.get(CONF_ID) % 10, + hass.data[DOMAIN][CONF_API], + ) + pwm.append(GryfYamlPwm(device)) + + async_add_entities(lights) + async_add_entities(pwm) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Config flow for Light platform.""" + lights = [] + pwm = [] + + for conf in config_entry.data[CONF_DEVICES]: + if conf.get(CONF_TYPE) == Platform.LIGHT: + device = _GryfOutput( + conf.get(CONF_NAME), + conf.get(CONF_ID) // 10, + conf.get(CONF_ID) % 10, + config_entry.runtime_data[CONF_API], + ) + lights.append(GryfConfigFlowLight(device, config_entry)) + elif conf.get(CONF_TYPE) == PLATFORM_PWM: + device = _GryfPwm( + conf.get(CONF_NAME), + conf.get(CONF_ID) // 10, + conf.get(CONF_ID) % 10, + config_entry.runtime_data[CONF_API], + ) + pwm.append(GryfConfigFlowPwm(device, config_entry)) + + async_add_entities(lights) + async_add_entities(pwm) + + +class GryfLightBase(LightEntity): + """Gryf Light entity base.""" + + _is_on = False + _device: _GryfDevice + _attr_color_mode = ColorMode.ONOFF + _attr_supported_color_modes = {ColorMode.ONOFF} + + @property + def is_on(self): + """Return is on.""" + + return self._is_on + + async def async_update(self, is_on): + """Update state.""" + + self._is_on = is_on + self.async_write_ha_state() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn light on.""" + + await self._device.turn_on() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn light off.""" + + await self._device.turn_off() + + +class GryfConfigFlowLight(GryfConfigFlowEntity, GryfLightBase): + """Gryf Smart config flow Light class.""" + + def __init__( + self, + device: _GryfDevice, + config_entry: ConfigEntry, + ) -> None: + """Init the Gryf Light.""" + + self._config_entry = config_entry + super().__init__(config_entry, device) + self._device.subscribe(self.async_update) + + +class GryfYamlLight(GryfYamlEntity, GryfLightBase): + """Gryf Smart Yaml Light class.""" + + def __init__(self, device: _GryfDevice) -> None: + """Init the Gryf Light.""" + + super().__init__(device) + device.subscribe(self.async_update) + + +class GryfPwmBase(LightEntity): + """Gryf Pwm entity base.""" + + _is_on = False + _brightness = 0 + _device: _GryfDevice + _attr_color_mode = ColorMode.BRIGHTNESS + _attr_supported_color_modes = {ColorMode.BRIGHTNESS} + + @property + def brightness(self) -> int | None: + """The brightness property.""" + return self._brightness + + @property + def is_on(self) -> bool: + """The is_on property.""" + + return self._is_on + + async def async_update(self, brightness): + """Update state.""" + + self._is_on = bool(brightness) + self._brightness = brightness + self.async_write_ha_state() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn pwm on.""" + brightness = kwargs.get("brightness") + if brightness is not None: + percentage_brightness = int((brightness / 255) * 100) + await self._device.set_level(percentage_brightness) + + await self._device.turn_on() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn pwm off.""" + + await self._device.turn_off() + + +class GryfConfigFlowPwm(GryfConfigFlowEntity, GryfPwmBase): + """Gryf Smart config flow Light class.""" + + def __init__( + self, + device: _GryfDevice, + config_entry: ConfigEntry, + ) -> None: + """Init the Gryf Light.""" + + self._config_entry = config_entry + super().__init__(config_entry, device) + self._device.subscribe(self.async_update) + + +class GryfYamlPwm(GryfYamlEntity, GryfPwmBase): + """Gryf Smart Yaml Light class.""" + + def __init__(self, device: _GryfDevice) -> None: + """Init the Gryf Light.""" + + super().__init__(device) + device.subscribe(self.async_update) diff --git a/homeassistant/components/gryfsmart/manifest.json b/homeassistant/components/gryfsmart/manifest.json new file mode 100644 index 00000000000000..2fb9bb0d92d167 --- /dev/null +++ b/homeassistant/components/gryfsmart/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "gryfsmart", + "name": "Gryf Smart", + "codeowners": ["@karlowiczpl"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/gryfsmart", + "homekit": {}, + "iot_class": "local_push", + "quality_scale": "bronze", + "requirements": ["pygryfsmart==0.1.9"], + "ssdp": [], + "zeroconf": [] +} diff --git a/homeassistant/components/gryfsmart/quality_scale.yaml b/homeassistant/components/gryfsmart/quality_scale.yaml new file mode 100644 index 00000000000000..76b8d347408bf3 --- /dev/null +++ b/homeassistant/components/gryfsmart/quality_scale.yaml @@ -0,0 +1,60 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: todo + config-entry-unloading: todo + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: todo + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: todo + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/gryfsmart/schema.py b/homeassistant/components/gryfsmart/schema.py new file mode 100644 index 00000000000000..fa25a6c7f900c5 --- /dev/null +++ b/homeassistant/components/gryfsmart/schema.py @@ -0,0 +1,73 @@ +"""Config schema for Gryf Smart Integration.""" + +import voluptuous as vol + +from homeassistant.helpers import config_validation as cv + +from .const import ( + CONF_DEVICE_CLASS, + CONF_HYSTERESIS, + CONF_ID, + CONF_MODULE_COUNT, + CONF_NAME, + CONF_OUT_ID, + CONF_PORT, + CONF_TEMP_ID, + DOMAIN, + PLATFORM_BINARY_SENSOR, + PLATFORM_INPUT, + PLATFORM_LIGHT, + PLATFORM_PWM, + PLATFORM_SWITCH, + PLATFORM_THERMOSTAT, +) + +STANDARD_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_ID): cv.positive_int, + } +) +DEVICE_CLASS_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_ID): cv.positive_int, + vol.Optional(CONF_DEVICE_CLASS): cv.string, + } +) +CLIMATE_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_OUT_ID): cv.positive_int, + vol.Required(CONF_TEMP_ID): cv.positive_int, + vol.Optional(CONF_HYSTERESIS): cv.positive_int, + } +) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_PORT): cv.string, + vol.Required(CONF_MODULE_COUNT): cv.positive_int, + vol.Optional(PLATFORM_PWM): vol.All(cv.ensure_list, [STANDARD_SCHEMA]), + vol.Optional(PLATFORM_LIGHT): vol.All( + cv.ensure_list, [STANDARD_SCHEMA] + ), + vol.Optional(PLATFORM_INPUT): vol.All( + cv.ensure_list, [STANDARD_SCHEMA] + ), + vol.Optional(PLATFORM_BINARY_SENSOR): vol.All( + cv.ensure_list, [DEVICE_CLASS_SCHEMA] + ), + vol.Optional(PLATFORM_SWITCH): vol.All( + cv.ensure_list, [DEVICE_CLASS_SCHEMA] + ), + vol.Optional(PLATFORM_THERMOSTAT): vol.All( + cv.ensure_list, [CLIMATE_SCHEMA] + ), + } + ) + }, + extra=vol.ALLOW_EXTRA, +) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d0a8e821f8d2f6..569039eadcd581 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -245,6 +245,7 @@ "gpslogger", "gree", "growatt_server", + "gryfsmart", "guardian", "habitica", "harmony", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 026eab30f8fdb1..a1d97f8e5fcc35 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2450,6 +2450,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "gryfsmart": { + "name": "Gryf Smart", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "gstreamer": { "name": "GStreamer", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index fe7ea7fc5cef40..f6adcde08411fc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1980,6 +1980,9 @@ pyfttt==0.3 # homeassistant.components.skybeacon pygatt[GATTTOOL]==4.0.5 +# homeassistant.components.gryfsmart +pygryfsmart==0.1.9 + # homeassistant.components.gtfs pygtfs==0.1.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a686bbd06330d1..13736501c359eb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1612,6 +1612,9 @@ pyfritzhome==0.6.14 # homeassistant.components.ifttt pyfttt==0.3 +# homeassistant.components.gryfsmart +pygryfsmart==0.1.9 + # homeassistant.components.hvv_departures pygti==0.9.4 diff --git a/tests/components/gryfsmart/__init__.py b/tests/components/gryfsmart/__init__.py new file mode 100644 index 00000000000000..4d856af146099c --- /dev/null +++ b/tests/components/gryfsmart/__init__.py @@ -0,0 +1 @@ +"""Tests for the Gryf Smart integration.""" diff --git a/tests/components/gryfsmart/test_config_flow.py b/tests/components/gryfsmart/test_config_flow.py new file mode 100644 index 00000000000000..bbbd30922f520c --- /dev/null +++ b/tests/components/gryfsmart/test_config_flow.py @@ -0,0 +1,120 @@ +"""Test for GryfSmartConfigFlow.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.gryfsmart.config_flow import ( + GryfSmartConfigFlow, + ping_connection, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + + +@pytest.fixture +def mock_ping_connection_success(): + """Mock the ping_connection to simulate successful connection.""" + with patch( + "homeassistant.components.gryfsmart.config_flow.ping_connection", + return_value=True, + ): + yield + + +@pytest.fixture +def mock_ping_connection_failure(): + """Mock the ping_connection to simulate connection failure.""" + with patch( + "homeassistant.components.gryfsmart.config_flow.ping_connection", + return_value=False, + ): + yield + + +@pytest.fixture +def mock_create_entry(): + """Mock creating a config entry.""" + with patch( + "homeassistant.components.gryfsmart.config_flow.GryfSmartConfigFlow.async_create_entry" + ) as mock: + mock.return_value = {"type": "form"} + yield mock + + +@pytest.fixture +async def config_flow(hass: HomeAssistant): + """Fixture to create an instance of the config flow.""" + + await async_setup_component(hass, "config", {}) + + config_flow = GryfSmartConfigFlow() + config_flow._edit_index = 0 + + hass.config_entries = MagicMock() + + return config_flow + + +async def test_step_user_success(config_flow, mock_ping_connection_success) -> None: + """Test the user input step for a valid configuration.""" + user_input = {"port": "/dev/ttyUSB0", "module_count": 2} + + result = await config_flow.async_step_user(user_input) + + assert result["type"] == "menu" + assert result["step_id"] == "device_menu" + + +async def test_step_add_device(config_flow) -> None: + """Test adding a new device.""" + user_input = {"type": "sensor", "name": "Sensor 1", "id": 1234, "extra": 10} + + result = await config_flow.async_step_add_device(user_input) + + assert len(config_flow._config_data["devices"]) == 1 + assert config_flow._config_data["devices"][0]["name"] == "Sensor 1" + assert result["type"] == "menu" + + +async def test_step_edit_device(config_flow) -> None: + """Test editing an existing device.""" + user_input = {"type": "sensor", "name": "Sensor 1", "id": 1234} + await config_flow.async_step_add_device(user_input) + + user_input_edit = { + "type": "sensor", + "name": "Updated Sensor 1", + "id": 5678, + "extra": 20, + } + + result = await config_flow.async_step_edit_device_details(user_input_edit) + + assert config_flow._config_data["devices"][0]["name"] == "Updated Sensor 1" + assert result["type"] == "menu" + + +async def test_step_finish(config_flow, mock_create_entry) -> None: + """Test finishing the config flow.""" + + user_input = {"port": "/dev/ttyUSB0", "module_count": 2} + await config_flow.async_step_user(user_input) + + result = await config_flow.async_step_finish() + + mock_create_entry.assert_called_once() + + assert result["type"] == "form" + + +async def test_ping_connection_success(mock_ping_connection_success) -> None: + """Test the ping_connection function for success.""" + result = await ping_connection("/dev/ttyUSB0") + assert result is True + + +async def test_ping_connection_failure(mock_ping_connection_failure) -> None: + """Test the ping_connection function for failure.""" + result = await ping_connection("/dev/invalid") + assert result is False