diff --git a/config/configuration.yaml b/config/configuration.yaml index 93a4477..2a7b256 100644 --- a/config/configuration.yaml +++ b/config/configuration.yaml @@ -29,4 +29,21 @@ sensor: test_humidity: # unit_of_measurement: "%" value_template: "{{ 43 }}" -# device_class: humidity + device_class: humidity + +input_boolean: + dummy_heater: + name: "Dummy Heater" + +climate: + - platform: generic_thermostat + name: "Template Climate" + heater: switch.dummy_heater # Create or use a dummy switch + target_sensor: sensor.test_temperature + min_temp: 15 + max_temp: 30 + ac_mode: false + target_temp: 22.5 + cold_tolerance: 0.5 + hot_tolerance: 0.5 + initial_hvac_mode: "heat" diff --git a/custom_components/apparent_temperature/__init__.py b/custom_components/apparent_temperature/__init__.py index 6b2e83e..d733e11 100644 --- a/custom_components/apparent_temperature/__init__.py +++ b/custom_components/apparent_temperature/__init__.py @@ -4,3 +4,25 @@ For more details about this integration, please refer to https://github.com/Limych/ha-temperature-feeling """ + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Handle setup of config entry.""" + hass.data.setdefault(DOMAIN, {}) + + await hass.config_entries.async_forward_entry_setups( + config_entry, [Platform.SENSOR] + ) + + return True + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle removal of a config entry.""" + hass.data[DOMAIN].pop(entry.entry_id, None) diff --git a/custom_components/apparent_temperature/config_flow.py b/custom_components/apparent_temperature/config_flow.py new file mode 100644 index 0000000..3f6f4a2 --- /dev/null +++ b/custom_components/apparent_temperature/config_flow.py @@ -0,0 +1,144 @@ +"""Homeassistant Config Flow for integration.""" + +from typing import Any + +import voluptuous as vol +from homeassistant import config_entries +from homeassistant.config_entries import ( + ConfigFlowResult, +) +from homeassistant.helpers.selector import selector + +from .const import DOMAIN + +MANUAL_SETUP_SCHEMA = vol.Schema( + { + vol.Required("name"): str, + vol.Required("temperature"): selector( + {"entity": {"domain": "sensor", "device_class": "temperature"}} + ), + vol.Required("humidity"): selector( + {"entity": {"domain": "sensor", "device_class": "humidity"}} + ), + vol.Optional("wind_speed"): selector( + {"entity": {"domain": "sensor", "device_class": "wind_speed"}} + ), + } +) + +WEATHER_SETUP_SCHEMA = vol.Schema( + { + vol.Required("name"): str, + vol.Required("weather"): selector({"entity": {"domain": "weather"}}), + } +) + +CLIMATE_SETUP_SCHEMA = vol.Schema( + { + vol.Required("name"): str, + vol.Required("climate"): selector({"entity": {"domain": "climate"}}), + } +) + +USER_STEP_TYPE = "type" +USER_STEP_TYPE_MANUAL_OPTION = "type_manual" +USER_STEP_TYPE_WEATHER_OPTION = "type_weather" +USER_STEP_TYPE_CLIMATE_OPTION = "type_climate" + +USER_STEP_SCHEMA = vol.Schema( + { + vol.Required(USER_STEP_TYPE): selector( + { + "select": { + "options": [ + { + "value": USER_STEP_TYPE_MANUAL_OPTION, + "label": "Manual", + }, + { + "value": USER_STEP_TYPE_WEATHER_OPTION, + "label": "Weather entity", + }, + { + "value": USER_STEP_TYPE_CLIMATE_OPTION, + "label": "Climate entity", + }, + ], + "mode": "list", + } + } + ) + } +) + + +class ApparentTemperatureConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Example config flow.""" + + VERSION = 1 + MINOR_VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Set up integration.""" + errors: dict[str, str] = {} + + if user_input is not None: + user_step_type = user_input[USER_STEP_TYPE] + if user_step_type == USER_STEP_TYPE_MANUAL_OPTION: + return await self.async_step_manual_config() + + if user_step_type == USER_STEP_TYPE_WEATHER_OPTION: + return await self.async_step_weather_config() + + if user_step_type == USER_STEP_TYPE_CLIMATE_OPTION: + return await self.async_step_climate_config() + + errors[USER_STEP_TYPE] = "Not supported" + + return self.async_show_form( + step_id="user", data_schema=USER_STEP_SCHEMA, errors=errors + ) + + async def async_step_climate_config( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Set up integration for climate entity.""" + if user_input is not None: + return self.async_create_entry( + title=user_input["name"], + data=user_input, + ) + + return self.async_show_form( + step_id="climate_config", data_schema=CLIMATE_SETUP_SCHEMA + ) + + async def async_step_weather_config( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Set up integration for weather entity.""" + if user_input is not None: + return self.async_create_entry( + title=user_input["name"], + data=user_input, + ) + + return self.async_show_form( + step_id="weather_config", data_schema=WEATHER_SETUP_SCHEMA + ) + + async def async_step_manual_config( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Set up integration manually, using provided entities.""" + if user_input is not None: + return self.async_create_entry( + title=user_input["name"], + data=user_input, + ) + + return self.async_show_form( + step_id="manual_config", data_schema=MANUAL_SETUP_SCHEMA + ) diff --git a/custom_components/apparent_temperature/manifest.json b/custom_components/apparent_temperature/manifest.json index 3692e6f..d3d7fab 100644 --- a/custom_components/apparent_temperature/manifest.json +++ b/custom_components/apparent_temperature/manifest.json @@ -9,7 +9,7 @@ "codeowners": [ "@Limych" ], - "config_flow": false, + "config_flow": true, "dependencies": [], "documentation": "https://github.com/Limych/ha-apparent-temperature", "iot_class": "calculated", diff --git a/custom_components/apparent_temperature/sensor.py b/custom_components/apparent_temperature/sensor.py index b4a9fbf..bf49750 100644 --- a/custom_components/apparent_temperature/sensor.py +++ b/custom_components/apparent_temperature/sensor.py @@ -6,11 +6,11 @@ from typing import Any import voluptuous as vol -from homeassistant.components.climate import ( +from homeassistant.components.climate.const import ( ATTR_CURRENT_HUMIDITY, ATTR_CURRENT_TEMPERATURE, ) -from homeassistant.components.climate import ( +from homeassistant.components.climate.const import ( DOMAIN as CLIMATE_DOMAIN, ) from homeassistant.components.group import expand_entity_ids @@ -19,16 +19,17 @@ SensorEntity, SensorStateClass, ) -from homeassistant.components.weather import ( +from homeassistant.components.weather.const import ( ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_TEMPERATURE, ATTR_WEATHER_TEMPERATURE_UNIT, ATTR_WEATHER_WIND_SPEED, ATTR_WEATHER_WIND_SPEED_UNIT, ) -from homeassistant.components.weather import ( +from homeassistant.components.weather.const import ( DOMAIN as WEATHER_DOMAIN, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, @@ -44,6 +45,7 @@ ) from homeassistant.core import ( Event, + EventStateChangedData, HomeAssistant, State, callback, @@ -52,7 +54,11 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, UndefinedType +from homeassistant.helpers.typing import ( + ConfigType, + DiscoveryInfoType, + UndefinedType, +) from homeassistant.util.unit_conversion import SpeedConverter, TemperatureConverter from .const import ( @@ -62,6 +68,7 @@ ATTR_TEMPERATURE_SOURCE_VALUE, ATTR_WIND_SPEED_SOURCE, ATTR_WIND_SPEED_SOURCE_VALUE, + DOMAIN, STARTUP_MESSAGE, ) @@ -76,6 +83,32 @@ ) +# pylint: disable=unused-argument +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up entity based on given config.""" + keys_to_check = ["temperature", "humidity", "wind_speed", "weather", "climate"] + + sources = [ + config_entry.data[key] for key in keys_to_check if key in config_entry.data + ] + + async_add_entities( + [ + ApparentTemperatureSensor( + unique_id=f"{config_entry.data['name']}_apparent_temperature".replace( + " ", "_" + ), + name=config_entry.data["name"], + sources=expand_entity_ids(hass, sources), + ) + ] + ) + + # pylint: disable=unused-argument async def async_setup_platform( hass: HomeAssistant, @@ -92,7 +125,7 @@ async def async_setup_platform( ApparentTemperatureSensor( config.get(CONF_UNIQUE_ID), config.get(CONF_NAME), - expand_entity_ids(hass, config.get(CONF_SOURCE)), + expand_entity_ids(hass, config.get(CONF_SOURCE) or []), ) ] ) @@ -136,6 +169,14 @@ def _compose_name(source_name: str) -> str: else source_name[:tpos] + "Apparent " + source_name[tpos:] ) + @property + def unique_id(self) -> str | None: + """Return a unique ID for this sensor.""" + if self._attr_unique_id is None: + return None + + return f"{DOMAIN}_{self._attr_unique_id}" + @property def name(self) -> str | UndefinedType | None: """Return the name of the sensor.""" @@ -160,7 +201,13 @@ def _setup_sources(self) -> list[str]: """Set sources for entity and return list of sources to track.""" entities = set() for entity_id in self._sources: - state: State = self.hass.states.get(entity_id) + state: State | None = self.hass.states.get(entity_id) + if state is None: + _LOGGER.warning( + "Entity ID '%s' does not exist or is unavailable.", entity_id + ) + continue + domain = split_entity_id(state.entity_id)[0] device_class = state.attributes.get(ATTR_DEVICE_CLASS) unit_of_measurement = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -199,30 +246,35 @@ def _setup_sources(self) -> list[str]: self._wind = entity_id entities.add(entity_id) - return list(entities) + return list(self._sources) async def async_added_to_hass(self) -> None: - """Register callbacks.""" + """Register callbacks and ensure entities are initialized.""" - # pylint: disable=unused-argument @callback - def sensor_state_listener(event: Event) -> None: # noqa: ARG001 - """Handle device state changes.""" + def sensor_state_listener(event: Event[EventStateChangedData]) -> None: # noqa: ARG001 + """Handle state changes of tracked entities.""" self.async_schedule_update_ha_state(force_refresh=True) - # pylint: disable=unused-argument - @callback - def sensor_startup(event: Event) -> None: # noqa: ARG001 - """Update entity on startup.""" + async def setup_listeners(event: Event | None = None) -> None: # noqa: ARG001 + """Set up listeners for state changes.""" + self._setup_sources() # Dynamically resolve sources + tracked_entities = self._setup_sources() + if not tracked_entities: + _LOGGER.warning( + "No valid entities found for apparent temperature sensor" + ) + return async_track_state_change_event( - self.hass, self._setup_sources(), sensor_state_listener + self.hass, tracked_entities, sensor_state_listener ) + self.async_schedule_update_ha_state(force_refresh=True) - self.async_schedule_update_ha_state( - force_refresh=True - ) # Force first update - - self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, sensor_startup) + # Listen for Home Assistant startup and ensure listeners are set + if self.hass.is_running: + await setup_listeners() + else: + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, setup_listeners) @staticmethod def _has_state(state: str | None) -> bool: @@ -238,7 +290,7 @@ def _get_temperature(self, entity_id: str | None) -> float | None: """Get temperature value (in °C) from entity.""" if entity_id is None: return None - state: State = self.hass.states.get(entity_id) + state: State | None = self.hass.states.get(entity_id) if state is None: return None @@ -253,7 +305,7 @@ def _get_temperature(self, entity_id: str | None) -> float | None: temperature = state.state entity_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - if not self._has_state(temperature): + if not self._has_state(temperature) or temperature is None: return None try: @@ -270,7 +322,7 @@ def _get_humidity(self, entity_id: str | None) -> float | None: """Get humidity value from entity.""" if entity_id is None: return None - state: State = self.hass.states.get(entity_id) + state: State | None = self.hass.states.get(entity_id) if state is None: return None @@ -282,7 +334,7 @@ def _get_humidity(self, entity_id: str | None) -> float | None: else: humidity = state.state - if not self._has_state(humidity): + if not self._has_state(humidity) or humidity is None: return None return float(humidity) @@ -291,7 +343,7 @@ def _get_wind_speed(self, entity_id: str | None) -> float | None: """Get wind speed value from entity.""" if entity_id is None: return 0.0 - state: State = self.hass.states.get(entity_id) + state: State | None = self.hass.states.get(entity_id) if state is None: return 0.0 @@ -303,7 +355,7 @@ def _get_wind_speed(self, entity_id: str | None) -> float | None: wind_speed = state.state entity_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - if not self._has_state(wind_speed): + if not self._has_state(wind_speed) or wind_speed is None: return None try: diff --git a/custom_components/apparent_temperature/strings.json b/custom_components/apparent_temperature/strings.json new file mode 100644 index 0000000..0970606 --- /dev/null +++ b/custom_components/apparent_temperature/strings.json @@ -0,0 +1,44 @@ +{ + "config": { + "step": { + "user": { + "title": "Apparent Temperature Setup", + "description": "Choose configuration type", + "data": { + "type": "Configuration type", + "manual_entities_label": "Manual entities", + "weather_entity_label": "Weather entity" + } + }, + "manual_config": { + "title": "Manual Setup", + "description": "Provide the necessary sensor entities.", + "data": { + "name": "Name", + "temperature": "Temperature Sensor", + "humidity": "Humidity Sensor", + "wind_speed": "Wind Speed Sensor" + } + }, + "weather_config": { + "title": "Weather Entity Setup", + "description": "Provide the necessary sensor entities.", + "data": { + "name": "Name", + "weather": "Weather entity" + } + }, + "climate_config": { + "title": "Climate Entity Setup", + "description": "Provide the necessary sensor entities.", + "data": { + "name": "Name", + "climate": "Climate entity" + } + } + }, + "error": { + "required_field": "This field is required." + } + } +} \ No newline at end of file diff --git a/custom_components/apparent_temperature/translations/en.json b/custom_components/apparent_temperature/translations/en.json new file mode 100644 index 0000000..0970606 --- /dev/null +++ b/custom_components/apparent_temperature/translations/en.json @@ -0,0 +1,44 @@ +{ + "config": { + "step": { + "user": { + "title": "Apparent Temperature Setup", + "description": "Choose configuration type", + "data": { + "type": "Configuration type", + "manual_entities_label": "Manual entities", + "weather_entity_label": "Weather entity" + } + }, + "manual_config": { + "title": "Manual Setup", + "description": "Provide the necessary sensor entities.", + "data": { + "name": "Name", + "temperature": "Temperature Sensor", + "humidity": "Humidity Sensor", + "wind_speed": "Wind Speed Sensor" + } + }, + "weather_config": { + "title": "Weather Entity Setup", + "description": "Provide the necessary sensor entities.", + "data": { + "name": "Name", + "weather": "Weather entity" + } + }, + "climate_config": { + "title": "Climate Entity Setup", + "description": "Provide the necessary sensor entities.", + "data": { + "name": "Name", + "climate": "Climate entity" + } + } + }, + "error": { + "required_field": "This field is required." + } + } +} \ No newline at end of file diff --git a/tests/test_sensor.py b/tests/test_sensor.py index efa6e5e..1b71e95 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -217,7 +217,7 @@ async def test_entity_initialization(hass: HomeAssistant): entity.hass = hass - assert entity.unique_id == TEST_UNIQUE_ID + assert entity.unique_id == "apparent_temperature_test_id" assert entity.name == TEST_NAME assert entity.device_class == NumberDeviceClass.TEMPERATURE assert entity.state_class == SensorStateClass.MEASUREMENT