diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index 8f89ec88271f56..c7a2baa8c3707c 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_push", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.7.1"] + "requirements": ["aioairzone-cloud==0.7.2"] } diff --git a/homeassistant/components/bayesian/__init__.py b/homeassistant/components/bayesian/__init__.py index e6f865b5656fe4..c93f6b7fb0be5c 100644 --- a/homeassistant/components/bayesian/__init__.py +++ b/homeassistant/components/bayesian/__init__.py @@ -1,6 +1,24 @@ """The bayesian component.""" -from homeassistant.const import Platform +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant -DOMAIN = "bayesian" -PLATFORMS = [Platform.BINARY_SENSOR] +from .const import PLATFORMS + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Bayesian from a config entry.""" + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(update_listener)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Bayesian config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + hass.config_entries.async_schedule_reload(entry.entry_id) diff --git a/homeassistant/components/bayesian/binary_sensor.py b/homeassistant/components/bayesian/binary_sensor.py index 32f439839914f5..0651c916eb0f8e 100644 --- a/homeassistant/components/bayesian/binary_sensor.py +++ b/homeassistant/components/bayesian/binary_sensor.py @@ -16,6 +16,7 @@ BinarySensorDeviceClass, BinarySensorEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ABOVE, CONF_BELOW, @@ -32,7 +33,10 @@ from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback from homeassistant.exceptions import ConditionError, TemplateError from homeassistant.helpers import condition, config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.event import ( TrackTemplate, TrackTemplateResult, @@ -44,7 +48,6 @@ from homeassistant.helpers.template import Template, result_as_boolean from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN, PLATFORMS from .const import ( ATTR_OBSERVATIONS, ATTR_OCCURRED_OBSERVATION_ENTITIES, @@ -60,6 +63,8 @@ CONF_TO_STATE, DEFAULT_NAME, DEFAULT_PROBABILITY_THRESHOLD, + DOMAIN, + PLATFORMS, ) from .helpers import Observation from .issues import raise_mirrored_entries, raise_no_prob_given_false @@ -67,7 +72,13 @@ _LOGGER = logging.getLogger(__name__) -def _above_greater_than_below(config: dict[str, Any]) -> dict[str, Any]: +def above_greater_than_below(config: dict[str, Any]) -> dict[str, Any]: + """Validate above and below options. + + If the observation is of type/platform NUMERIC_STATE, then ensure that the + value given for 'above' is not greater than that for 'below'. Also check + that at least one of the two is specified. + """ if config[CONF_PLATFORM] == CONF_NUMERIC_STATE: above = config.get(CONF_ABOVE) below = config.get(CONF_BELOW) @@ -76,9 +87,7 @@ def _above_greater_than_below(config: dict[str, Any]) -> dict[str, Any]: "For bayesian numeric state for entity: %s at least one of 'above' or 'below' must be specified", config[CONF_ENTITY_ID], ) - raise vol.Invalid( - "For bayesian numeric state at least one of 'above' or 'below' must be specified." - ) + raise vol.Invalid("above_or_below") if above is not None and below is not None: if above > below: _LOGGER.error( @@ -86,7 +95,7 @@ def _above_greater_than_below(config: dict[str, Any]) -> dict[str, Any]: above, below, ) - raise vol.Invalid("'above' is greater than 'below'") + raise vol.Invalid("above_below") return config @@ -102,11 +111,16 @@ def _above_greater_than_below(config: dict[str, Any]) -> dict[str, Any]: }, required=True, ), - _above_greater_than_below, + above_greater_than_below, ) -def _no_overlapping(configs: list[dict]) -> list[dict]: +def no_overlapping(configs: list[dict]) -> list[dict]: + """Validate that intervals are not overlapping. + + For a list of observations ensure that there are no overlapping intervals + for NUMERIC_STATE observations for the same entity. + """ numeric_configs = [ config for config in configs if config[CONF_PLATFORM] == CONF_NUMERIC_STATE ] @@ -129,11 +143,16 @@ class NumericConfig(NamedTuple): for i, tup in enumerate(intervals): if len(intervals) > i + 1 and tup.below > intervals[i + 1].above: + _LOGGER.error( + "Ranges for bayesian numeric state entities must not overlap, but %s has overlapping ranges, above:%s, below:%s overlaps with above:%s, below:%s", + ent_id, + tup.above, + tup.below, + intervals[i + 1].above, + intervals[i + 1].below, + ) raise vol.Invalid( - "Ranges for bayesian numeric state entities must not overlap, " - f"but {ent_id} has overlapping ranges, above:{tup.above}, " - f"below:{tup.below} overlaps with above:{intervals[i + 1].above}, " - f"below:{intervals[i + 1].below}." + "overlapping_ranges", ) return configs @@ -168,7 +187,7 @@ class NumericConfig(NamedTuple): vol.All( cv.ensure_list, [vol.Any(TEMPLATE_SCHEMA, STATE_SCHEMA, NUMERIC_STATE_SCHEMA)], - _no_overlapping, + no_overlapping, ) ), vol.Required(CONF_PRIOR): vol.Coerce(float), @@ -194,9 +213,13 @@ async def async_setup_platform( async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Bayesian Binary sensor.""" + """Set up the Bayesian Binary sensor from a yaml config.""" + _LOGGER.debug( + "Setting up config entry for Bayesian sensor: '%s' with %s observations", + config[CONF_NAME], + len(config.get(CONF_OBSERVATIONS, [])), + ) await async_setup_reload_service(hass, DOMAIN, PLATFORMS) - name: str = config[CONF_NAME] unique_id: str | None = config.get(CONF_UNIQUE_ID) observations: list[ConfigType] = config[CONF_OBSERVATIONS] @@ -231,6 +254,42 @@ async def async_setup_platform( ) +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the Bayesian Binary sensor from a config entry.""" + _LOGGER.debug( + "Setting up config entry for Bayesian sensor: '%s' with %s observations", + config_entry.options[CONF_NAME], + len(config_entry.subentries), + ) + config = config_entry.options + name: str = config[CONF_NAME] + unique_id: str | None = config.get(CONF_UNIQUE_ID, config_entry.entry_id) + observations: list[ConfigType] = [ + dict(subentry.data) for subentry in config_entry.subentries.values() + ] + prior: float = config[CONF_PRIOR] + probability_threshold: float = config[CONF_PROBABILITY_THRESHOLD] + device_class: BinarySensorDeviceClass | None = config.get(CONF_DEVICE_CLASS) + + async_add_entities( + [ + BayesianBinarySensor( + name, + unique_id, + prior, + observations, + probability_threshold, + device_class, + ) + ] + ) + + class BayesianBinarySensor(BinarySensorEntity): """Representation of a Bayesian sensor.""" @@ -248,6 +307,7 @@ def __init__( """Initialize the Bayesian sensor.""" self._attr_name = name self._attr_unique_id = unique_id and f"bayesian-{unique_id}" + self._observations = [ Observation( entity_id=observation.get(CONF_ENTITY_ID), @@ -432,7 +492,7 @@ def _calculate_new_probability(self) -> float: 1 - observation.prob_given_false, ) continue - # observation.observed is None + # Entity exists but observation.observed is None if observation.entity_id is not None: _LOGGER.debug( ( @@ -495,7 +555,10 @@ def _build_observations_by_template(self) -> dict[Template, list[Observation]]: for observation in self._observations: if observation.value_template is None: continue - + if isinstance(observation.value_template, str): + observation.value_template = Template( + observation.value_template, hass=self.hass + ) template = observation.value_template observations_by_template.setdefault(template, []).append(observation) diff --git a/homeassistant/components/bayesian/config_flow.py b/homeassistant/components/bayesian/config_flow.py new file mode 100644 index 00000000000000..ce13cf43d8cade --- /dev/null +++ b/homeassistant/components/bayesian/config_flow.py @@ -0,0 +1,646 @@ +"""Config flow for the Bayesian integration.""" + +from collections.abc import Mapping +from enum import StrEnum +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components.alarm_control_panel import DOMAIN as ALARM_DOMAIN +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) +from homeassistant.components.calendar import DOMAIN as CALENDAR_DOMAIN +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN +from homeassistant.components.cover import DOMAIN as COVER_DOMAIN +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.components.input_boolean import DOMAIN as INPUT_BOOLEAN_DOMAIN +from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN +from homeassistant.components.input_text import DOMAIN as INPUT_TEXT_DOMAIN +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN +from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN +from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN +from homeassistant.components.person import DOMAIN as PERSON_DOMAIN +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.sun import DOMAIN as SUN_DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.todo import DOMAIN as TODO_DOMAIN +from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN +from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN +from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlowResult, + ConfigSubentry, + ConfigSubentryData, + ConfigSubentryFlow, + SubentryFlowResult, +) +from homeassistant.const import ( + CONF_ABOVE, + CONF_BELOW, + CONF_DEVICE_CLASS, + CONF_ENTITY_ID, + CONF_NAME, + CONF_PLATFORM, + CONF_STATE, + CONF_VALUE_TEMPLATE, +) +from homeassistant.core import callback +from homeassistant.helpers import selector, translation +from homeassistant.helpers.schema_config_entry_flow import ( + SchemaCommonFlowHandler, + SchemaConfigFlowHandler, + SchemaFlowError, + SchemaFlowFormStep, + SchemaFlowMenuStep, +) + +from .binary_sensor import above_greater_than_below, no_overlapping +from .const import ( + CONF_OBSERVATIONS, + CONF_P_GIVEN_F, + CONF_P_GIVEN_T, + CONF_PRIOR, + CONF_PROBABILITY_THRESHOLD, + CONF_TEMPLATE, + CONF_TO_STATE, + DEFAULT_NAME, + DEFAULT_PROBABILITY_THRESHOLD, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) +USER = "user" +OBSERVATION_SELECTOR = "observation_selector" +ALLOWED_STATE_DOMAINS = [ + ALARM_DOMAIN, + BINARY_SENSOR_DOMAIN, + CALENDAR_DOMAIN, + CLIMATE_DOMAIN, + COVER_DOMAIN, + DEVICE_TRACKER_DOMAIN, + INPUT_BOOLEAN_DOMAIN, + INPUT_NUMBER_DOMAIN, + INPUT_TEXT_DOMAIN, + LIGHT_DOMAIN, + MEDIA_PLAYER_DOMAIN, + NOTIFY_DOMAIN, + NUMBER_DOMAIN, + PERSON_DOMAIN, + "schedule", # Avoids an import that would introduce a dependency. + SELECT_DOMAIN, + SENSOR_DOMAIN, + SUN_DOMAIN, + SWITCH_DOMAIN, + TODO_DOMAIN, + UPDATE_DOMAIN, + WEATHER_DOMAIN, +] +ALLOWED_NUMERIC_DOMAINS = [ + SENSOR_DOMAIN, + INPUT_NUMBER_DOMAIN, + NUMBER_DOMAIN, + TODO_DOMAIN, + ZONE_DOMAIN, +] + + +class ObservationTypes(StrEnum): + """StrEnum for all the different observation types.""" + + STATE = CONF_STATE + NUMERIC_STATE = "numeric_state" + TEMPLATE = CONF_TEMPLATE + + +class OptionsFlowSteps(StrEnum): + """StrEnum for all the different options flow steps.""" + + INIT = "init" + ADD_OBSERVATION = OBSERVATION_SELECTOR + + +OPTIONS_SCHEMA = vol.Schema( + { + vol.Required( + CONF_PROBABILITY_THRESHOLD, default=DEFAULT_PROBABILITY_THRESHOLD * 100 + ): vol.All( + selector.NumberSelector( + selector.NumberSelectorConfig( + mode=selector.NumberSelectorMode.SLIDER, + step=1.0, + min=0, + max=100, + unit_of_measurement="%", + ), + ), + vol.Range( + min=0, + max=100, + min_included=False, + max_included=False, + msg="extreme_threshold_error", + ), + ), + vol.Required(CONF_PRIOR, default=DEFAULT_PROBABILITY_THRESHOLD * 100): vol.All( + selector.NumberSelector( + selector.NumberSelectorConfig( + mode=selector.NumberSelectorMode.SLIDER, + step=1.0, + min=0, + max=100, + unit_of_measurement="%", + ), + ), + vol.Range( + min=0, + max=100, + min_included=False, + max_included=False, + msg="extreme_prior_error", + ), + ), + vol.Optional(CONF_DEVICE_CLASS): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[cls.value for cls in BinarySensorDeviceClass], + mode=selector.SelectSelectorMode.DROPDOWN, + translation_key="binary_sensor_device_class", + sort=True, + ), + ), + } +) + +CONFIG_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME, default=DEFAULT_NAME): selector.TextSelector(), + } +).extend(OPTIONS_SCHEMA.schema) + +OBSERVATION_BOILERPLATE = vol.Schema( + { + vol.Required(CONF_P_GIVEN_T): vol.All( + selector.NumberSelector( + selector.NumberSelectorConfig( + mode=selector.NumberSelectorMode.SLIDER, + step=1.0, + min=0, + max=100, + unit_of_measurement="%", + ), + ), + vol.Range( + min=0, + max=100, + min_included=False, + max_included=False, + msg="extreme_prob_given_error", + ), + ), + vol.Required(CONF_P_GIVEN_F): vol.All( + selector.NumberSelector( + selector.NumberSelectorConfig( + mode=selector.NumberSelectorMode.SLIDER, + step=1.0, + min=0, + max=100, + unit_of_measurement="%", + ), + ), + vol.Range( + min=0, + max=100, + min_included=False, + max_included=False, + msg="extreme_prob_given_error", + ), + ), + vol.Required(CONF_NAME): selector.TextSelector(), + } +) + +STATE_SUBSCHEMA = vol.Schema( + { + vol.Required(CONF_ENTITY_ID): selector.EntitySelector( + selector.EntitySelectorConfig(domain=ALLOWED_STATE_DOMAINS) + ), + vol.Required(CONF_TO_STATE): selector.TextSelector( + selector.TextSelectorConfig( + multiline=False, type=selector.TextSelectorType.TEXT, multiple=False + ) # ideally this would be a state selector context-linked to the above entity. + ), + }, +).extend(OBSERVATION_BOILERPLATE.schema) + +NUMERIC_STATE_SUBSCHEMA = vol.Schema( + { + vol.Required(CONF_ENTITY_ID): selector.EntitySelector( + selector.EntitySelectorConfig(domain=ALLOWED_NUMERIC_DOMAINS) + ), + vol.Optional(CONF_ABOVE): selector.NumberSelector( + selector.NumberSelectorConfig( + mode=selector.NumberSelectorMode.BOX, step="any" + ), + ), + vol.Optional(CONF_BELOW): selector.NumberSelector( + selector.NumberSelectorConfig( + mode=selector.NumberSelectorMode.BOX, step="any" + ), + ), + }, +).extend(OBSERVATION_BOILERPLATE.schema) + + +TEMPLATE_SUBSCHEMA = vol.Schema( + { + vol.Required(CONF_VALUE_TEMPLATE): selector.TemplateSelector( + selector.TemplateSelectorConfig(), + ), + }, +).extend(OBSERVATION_BOILERPLATE.schema) + + +def _convert_percentages_to_fractions( + data: dict[str, str | float | int], +) -> dict[str, str | float]: + """Convert percentage probability values in a dictionary to fractions for storing in the config entry.""" + probabilities = [ + CONF_P_GIVEN_T, + CONF_P_GIVEN_F, + CONF_PRIOR, + CONF_PROBABILITY_THRESHOLD, + ] + return { + key: ( + value / 100 + if isinstance(value, (int, float)) and key in probabilities + else value + ) + for key, value in data.items() + } + + +def _convert_fractions_to_percentages( + data: dict[str, str | float], +) -> dict[str, str | float]: + """Convert fraction probability values in a dictionary to percentages for loading into the UI.""" + probabilities = [ + CONF_P_GIVEN_T, + CONF_P_GIVEN_F, + CONF_PRIOR, + CONF_PROBABILITY_THRESHOLD, + ] + return { + key: ( + value * 100 + if isinstance(value, (int, float)) and key in probabilities + else value + ) + for key, value in data.items() + } + + +def _select_observation_schema( + obs_type: ObservationTypes, +) -> vol.Schema: + """Return the schema for editing the correct observation (SubEntry) type.""" + if obs_type == str(ObservationTypes.STATE): + return STATE_SUBSCHEMA + if obs_type == str(ObservationTypes.NUMERIC_STATE): + return NUMERIC_STATE_SUBSCHEMA + + return TEMPLATE_SUBSCHEMA + + +async def _get_base_suggested_values( + handler: SchemaCommonFlowHandler, +) -> dict[str, Any]: + """Return suggested values for the base sensor options.""" + + return _convert_fractions_to_percentages(dict(handler.options)) + + +def _get_observation_values_for_editing( + subentry: ConfigSubentry, +) -> dict[str, Any]: + """Return the values for editing in the observation subentry.""" + + return _convert_fractions_to_percentages(dict(subentry.data)) + + +async def _validate_user( + handler: SchemaCommonFlowHandler, user_input: dict[str, Any] +) -> dict[str, Any]: + """Modify user input to convert to fractions for storage. Validation is done entirely by the schemas.""" + user_input = _convert_percentages_to_fractions(user_input) + return {**user_input} + + +def _validate_observation_subentry( + obs_type: ObservationTypes, + user_input: dict[str, Any], + other_subentries: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + """Validate an observation input and manually update options with observations as they are nested items.""" + + if user_input[CONF_P_GIVEN_T] == user_input[CONF_P_GIVEN_F]: + raise SchemaFlowError("equal_probabilities") + user_input = _convert_percentages_to_fractions(user_input) + + # Save the observation type in the user input as it is needed in binary_sensor.py + user_input[CONF_PLATFORM] = str(obs_type) + + # Additional validation for multiple numeric state observations + if ( + user_input[CONF_PLATFORM] == ObservationTypes.NUMERIC_STATE + and other_subentries is not None + ): + _LOGGER.debug( + "Comparing with other subentries: %s", [*other_subentries, user_input] + ) + try: + above_greater_than_below(user_input) + no_overlapping([*other_subentries, user_input]) + except vol.Invalid as err: + raise SchemaFlowError(err) from err + + _LOGGER.debug("Processed observation with settings: %s", user_input) + return user_input + + +async def _validate_subentry_from_config_entry( + handler: SchemaCommonFlowHandler, user_input: dict[str, Any] +) -> dict[str, Any]: + # Standard behavior is to merge the result with the options. + # In this case, we want to add a subentry so we update the options directly. + observations: list[dict[str, Any]] = handler.options.setdefault( + CONF_OBSERVATIONS, [] + ) + + if handler.parent_handler.cur_step is not None: + user_input[CONF_PLATFORM] = handler.parent_handler.cur_step["step_id"] + user_input = _validate_observation_subentry( + user_input[CONF_PLATFORM], + user_input, + other_subentries=handler.options[CONF_OBSERVATIONS], + ) + observations.append(user_input) + return {} + + +async def _get_description_placeholders( + handler: SchemaCommonFlowHandler, +) -> dict[str, str]: + # Current step is None when were are about to start the first step + if handler.parent_handler.cur_step is None: + return {"url": "https://www.home-assistant.io/integrations/bayesian/"} + return { + "parent_sensor_name": handler.options[CONF_NAME], + "device_class_on": translation.async_translate_state( + handler.parent_handler.hass, + "on", + BINARY_SENSOR_DOMAIN, + platform=None, + translation_key=None, + device_class=handler.options.get(CONF_DEVICE_CLASS, None), + ), + "device_class_off": translation.async_translate_state( + handler.parent_handler.hass, + "off", + BINARY_SENSOR_DOMAIN, + platform=None, + translation_key=None, + device_class=handler.options.get(CONF_DEVICE_CLASS, None), + ), + } + + +async def _get_observation_menu_options(handler: SchemaCommonFlowHandler) -> list[str]: + """Return the menu options for the observation selector.""" + options = [typ.value for typ in ObservationTypes] + if handler.options.get(CONF_OBSERVATIONS): + options.append("finish") + return options + + +CONFIG_FLOW: dict[str, SchemaFlowMenuStep | SchemaFlowFormStep] = { + str(USER): SchemaFlowFormStep( + CONFIG_SCHEMA, + validate_user_input=_validate_user, + next_step=str(OBSERVATION_SELECTOR), + description_placeholders=_get_description_placeholders, + ), + str(OBSERVATION_SELECTOR): SchemaFlowMenuStep( + _get_observation_menu_options, + ), + str(ObservationTypes.STATE): SchemaFlowFormStep( + STATE_SUBSCHEMA, + next_step=str(OBSERVATION_SELECTOR), + validate_user_input=_validate_subentry_from_config_entry, + # Prevent the name of the bayesian sensor from being used as the suggested + # name of the observations + suggested_values=None, + description_placeholders=_get_description_placeholders, + ), + str(ObservationTypes.NUMERIC_STATE): SchemaFlowFormStep( + NUMERIC_STATE_SUBSCHEMA, + next_step=str(OBSERVATION_SELECTOR), + validate_user_input=_validate_subentry_from_config_entry, + suggested_values=None, + description_placeholders=_get_description_placeholders, + ), + str(ObservationTypes.TEMPLATE): SchemaFlowFormStep( + TEMPLATE_SUBSCHEMA, + next_step=str(OBSERVATION_SELECTOR), + validate_user_input=_validate_subentry_from_config_entry, + suggested_values=None, + description_placeholders=_get_description_placeholders, + ), + "finish": SchemaFlowFormStep(), +} + + +OPTIONS_FLOW: dict[str, SchemaFlowMenuStep | SchemaFlowFormStep] = { + str(OptionsFlowSteps.INIT): SchemaFlowFormStep( + OPTIONS_SCHEMA, + suggested_values=_get_base_suggested_values, + validate_user_input=_validate_user, + description_placeholders=_get_description_placeholders, + ), +} + + +class BayesianConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): + """Bayesian config flow.""" + + VERSION = 1 + MINOR_VERSION = 1 + + config_flow = CONFIG_FLOW + options_flow = OPTIONS_FLOW + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this integration.""" + return {"observation": ObservationSubentryFlowHandler} + + def async_config_entry_title(self, options: Mapping[str, str]) -> str: + """Return config entry title.""" + name: str = options[CONF_NAME] + return name + + @callback + def async_create_entry( + self, + data: Mapping[str, Any], + **kwargs: Any, + ) -> ConfigFlowResult: + """Finish config flow and create a config entry.""" + data = dict(data) + observations = data.pop(CONF_OBSERVATIONS) + subentries: list[ConfigSubentryData] = [ + ConfigSubentryData( + data=observation, + title=observation[CONF_NAME], + subentry_type="observation", + unique_id=None, + ) + for observation in observations + ] + + self.async_config_flow_finished(data) + return super().async_create_entry(data=data, subentries=subentries, **kwargs) + + +class ObservationSubentryFlowHandler(ConfigSubentryFlow): + """Handle subentry flow for adding and modifying a topic.""" + + async def step_common( + self, + user_input: dict[str, Any] | None, + obs_type: ObservationTypes, + reconfiguring: bool = False, + ) -> SubentryFlowResult: + """Use common logic within the named steps.""" + + errors: dict[str, str] = {} + + other_subentries = None + if obs_type == str(ObservationTypes.NUMERIC_STATE): + other_subentries = [ + dict(se.data) for se in self._get_entry().subentries.values() + ] + # If we are reconfiguring a subentry we don't want to compare with self + if reconfiguring: + sub_entry = self._get_reconfigure_subentry() + if other_subentries is not None: + other_subentries.remove(dict(sub_entry.data)) + + if user_input is not None: + try: + user_input = _validate_observation_subentry( + obs_type, + user_input, + other_subentries=other_subentries, + ) + if reconfiguring: + return self.async_update_and_abort( + self._get_entry(), + sub_entry, + title=user_input.get(CONF_NAME, sub_entry.data[CONF_NAME]), + data_updates=user_input, + ) + return self.async_create_entry( + title=user_input.get(CONF_NAME), + data=user_input, + ) + except SchemaFlowError as err: + errors["base"] = str(err) + + return self.async_show_form( + step_id="reconfigure" if reconfiguring else str(obs_type), + data_schema=self.add_suggested_values_to_schema( + data_schema=_select_observation_schema(obs_type), + suggested_values=_get_observation_values_for_editing(sub_entry) + if reconfiguring + else None, + ), + errors=errors, + description_placeholders={ + "parent_sensor_name": self._get_entry().title, + "device_class_on": translation.async_translate_state( + self.hass, + "on", + BINARY_SENSOR_DOMAIN, + platform=None, + translation_key=None, + device_class=self._get_entry().options.get(CONF_DEVICE_CLASS, None), + ), + "device_class_off": translation.async_translate_state( + self.hass, + "off", + BINARY_SENSOR_DOMAIN, + platform=None, + translation_key=None, + device_class=self._get_entry().options.get(CONF_DEVICE_CLASS, None), + ), + }, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """User flow to add a new observation.""" + + return self.async_show_menu( + step_id="user", + menu_options=[typ.value for typ in ObservationTypes], + ) + + async def async_step_state( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """User flow to add a state observation. Function name must be in the format async_step_{observation_type}.""" + + return await self.step_common( + user_input=user_input, obs_type=ObservationTypes.STATE + ) + + async def async_step_numeric_state( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """User flow to add a new numeric state observation, (a numeric range). Function name must be in the format async_step_{observation_type}.""" + + return await self.step_common( + user_input=user_input, obs_type=ObservationTypes.NUMERIC_STATE + ) + + async def async_step_template( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """User flow to add a new template observation. Function name must be in the format async_step_{observation_type}.""" + + return await self.step_common( + user_input=user_input, obs_type=ObservationTypes.TEMPLATE + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Enable the reconfigure button for observations. Function name must be async_step_reconfigure to be recognised by hass.""" + + sub_entry = self._get_reconfigure_subentry() + + return await self.step_common( + user_input=user_input, + obs_type=ObservationTypes(sub_entry.data[CONF_PLATFORM]), + reconfiguring=True, + ) diff --git a/homeassistant/components/bayesian/const.py b/homeassistant/components/bayesian/const.py index cac4237b4ecfc5..239c6cfa5c4ccf 100644 --- a/homeassistant/components/bayesian/const.py +++ b/homeassistant/components/bayesian/const.py @@ -1,5 +1,9 @@ """Consts for using in modules.""" +from homeassistant.const import Platform + +DOMAIN = "bayesian" +PLATFORMS = [Platform.BINARY_SENSOR] ATTR_OBSERVATIONS = "observations" ATTR_OCCURRED_OBSERVATION_ENTITIES = "occurred_observation_entities" ATTR_PROBABILITY = "probability" diff --git a/homeassistant/components/bayesian/issues.py b/homeassistant/components/bayesian/issues.py index b35c788053d3f1..35080949c6fbeb 100644 --- a/homeassistant/components/bayesian/issues.py +++ b/homeassistant/components/bayesian/issues.py @@ -5,7 +5,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir -from . import DOMAIN +from .const import DOMAIN from .helpers import Observation diff --git a/homeassistant/components/bayesian/manifest.json b/homeassistant/components/bayesian/manifest.json index df1ab9c7609aab..def56cb8898e69 100644 --- a/homeassistant/components/bayesian/manifest.json +++ b/homeassistant/components/bayesian/manifest.json @@ -2,8 +2,9 @@ "domain": "bayesian", "name": "Bayesian", "codeowners": ["@HarvsG"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/bayesian", - "integration_type": "helper", - "iot_class": "local_polling", + "integration_type": "service", + "iot_class": "calculated", "quality_scale": "internal" } diff --git a/homeassistant/components/bayesian/strings.json b/homeassistant/components/bayesian/strings.json index 00de79a22292b4..abf322a2b49c7f 100644 --- a/homeassistant/components/bayesian/strings.json +++ b/homeassistant/components/bayesian/strings.json @@ -14,5 +14,264 @@ "name": "[%key:common::action::reload%]", "description": "Reloads Bayesian sensors from the YAML-configuration." } + }, + "options": { + "error": { + "extreme_prior_error": "[%key:component::bayesian::config::error::extreme_prior_error%]", + "extreme_threshold_error": "[%key:component::bayesian::config::error::extreme_threshold_error%]", + "equal_probabilities": "[%key:component::bayesian::config::error::equal_probabilities%]", + "extreme_prob_given_error": "[%key:component::bayesian::config::error::extreme_prob_given_error%]" + }, + "step": { + "init": { + "title": "Sensor options", + "description": "These options affect how much evidence is required for the Bayesian sensor to be considered 'on'.", + "data": { + "probability_threshold": "[%key:component::bayesian::config::step::user::data::probability_threshold%]", + "prior": "[%key:component::bayesian::config::step::user::data::prior%]", + "device_class": "[%key:component::bayesian::config::step::user::data::device_class%]", + "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "probability_threshold": "[%key:component::bayesian::config::step::user::data_description::probability_threshold%]", + "prior": "[%key:component::bayesian::config::step::user::data_description::prior%]" + } + } + } + }, + "config": { + "error": { + "extreme_prior_error": "'Prior' set to 0% means that it is impossible for the sensor to show 'on' and 100% means it will never show 'off', use a close number like 0.1% or 99.9% instead", + "extreme_threshold_error": "'Probability threshold' set to 0% means that the sensor will always be 'on' and 100% mean it will always be 'off', use a close number like 0.1% or 99.9% instead", + "equal_probabilities": "If 'Probability given true' and 'Probability given false' are equal, this observation can have no effect, and is therefore redundant", + "extreme_prob_given_error": "If either 'Probability given false' or 'Probability given true' is 0 or 100 this will create certainties that override all other observations, use numbers close to 0 or 100 instead", + "above_below": "Invalid range: 'Above' must be less than 'Below' when both are set.", + "above_or_below": "Invalid range: At least one of 'Above' or 'Below' must be set.", + "overlapping_ranges": "Invalid range: The 'Above' and 'Below' values overlap with another observation for the same entity." + }, + "step": { + "user": { + "title": "Add a Bayesian sensor", + "description": "Create a binary sensor which observes the state of multiple sensors to estimate whether an event is occurring, or if something is true. See [the documentation]({url}) for more details.", + "data": { + "probability_threshold": "Probability threshold", + "prior": "Prior", + "device_class": "Device class", + "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "probability_threshold": "The probability above which the sensor will show as 'on'. 50% should produce the most accurate result. Use numbers greater than 50% if avoiding false positives is important, or vice-versa.", + "prior": "The baseline probabilty the sensor should be 'on', this is usually the percentage of time it is true. For example, for a sensor 'Everyone sleeping' it might be 8 hours a day, 33%.", + "device_class": "Choose the device class you would like the sensor to show as." + } + }, + "observation_selector": { + "title": "[%key:component::bayesian::config_subentries::observation::step::user::title%]", + "description": "[%key:component::bayesian::config_subentries::observation::step::user::description%]", + "menu_options": { + "state": "[%key:component::bayesian::config_subentries::observation::step::user::menu_options::state%]", + "numeric_state": "[%key:component::bayesian::config_subentries::observation::step::user::menu_options::numeric_state%]", + "template": "[%key:component::bayesian::config_subentries::observation::step::user::menu_options::template%]", + "finish": "Finish" + } + }, + "state": { + "title": "[%key:component::bayesian::config_subentries::observation::step::state::title%]", + "description": "[%key:component::bayesian::config_subentries::observation::step::state::description%]", + + "data": { + "name": "[%key:common::config_flow::data::name%]", + "entity_id": "[%key:component::bayesian::config_subentries::observation::step::state::data::entity_id%]", + "to_state": "[%key:component::bayesian::config_subentries::observation::step::state::data::to_state%]", + "prob_given_true": "[%key:component::bayesian::config_subentries::observation::step::state::data::prob_given_true%]", + "prob_given_false": "[%key:component::bayesian::config_subentries::observation::step::state::data::prob_given_false%]" + }, + "data_description": { + "name": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::name%]", + "entity_id": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::entity_id%]", + "to_state": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::to_state%]", + "prob_given_true": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::prob_given_true%]", + "prob_given_false": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::prob_given_false%]" + } + }, + "numeric_state": { + "title": "[%key:component::bayesian::config_subentries::observation::step::state::title%]", + "description": "[%key:component::bayesian::config_subentries::observation::step::state::description%]", + "data": { + "name": "[%key:common::config_flow::data::name%]", + "entity_id": "[%key:component::bayesian::config_subentries::observation::step::state::data::entity_id%]", + "above": "[%key:component::bayesian::config_subentries::observation::step::numeric_state::data::above%]", + "below": "[%key:component::bayesian::config_subentries::observation::step::numeric_state::data::below%]", + "prob_given_true": "[%key:component::bayesian::config_subentries::observation::step::state::data::prob_given_true%]", + "prob_given_false": "[%key:component::bayesian::config_subentries::observation::step::state::data::prob_given_false%]" + }, + "data_description": { + "name": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::name%]", + "entity_id": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::entity_id%]", + "above": "[%key:component::bayesian::config_subentries::observation::step::numeric_state::data_description::above%]", + "below": "[%key:component::bayesian::config_subentries::observation::step::numeric_state::data_description::below%]", + "prob_given_true": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::prob_given_true%]", + "prob_given_false": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::prob_given_false%]" + } + }, + "template": { + "title": "[%key:component::bayesian::config_subentries::observation::step::state::title%]", + "description": "[%key:component::bayesian::config_subentries::observation::step::template::description%]", + "data": { + "name": "[%key:common::config_flow::data::name%]", + "value_template": "[%key:component::bayesian::config_subentries::observation::step::template::data::value_template%]", + "prob_given_true": "[%key:component::bayesian::config_subentries::observation::step::state::data::prob_given_true%]", + "prob_given_false": "[%key:component::bayesian::config_subentries::observation::step::state::data::prob_given_false%]" + }, + "data_description": { + "name": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::name%]", + "value_template": "[%key:component::bayesian::config_subentries::observation::step::template::data_description::value_template%]", + "prob_given_true": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::prob_given_true%]", + "prob_given_false": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::prob_given_false%]" + } + } + } + }, + "config_subentries": { + "observation": { + "step": { + "user": { + "title": "Add an observation", + "description": "'Observations' are the sensor or template values that are monitored and then combined in order to inform the Bayesian sensor's final probability. Each observation will update the probability of the Bayesian sensor if it is detected, or if it is not detected. If the state of the entity becomes `unavailable` or `unknown` it will be ignored. If more than one state or more than one numeric range is configured for the same entity then inverse detections will be ignored.", + "menu_options": { + "state": "Add an observation for a sensor's state", + "numeric_state": "Add an observation for a numeric range", + "template": "Add an observation for a template" + } + }, + "state": { + "title": "Add a Bayesian sensor", + "description": "Add an observation which evaluates to `True` when the value of the sensor exactly matches *'To state'*. When `False`, it will update the prior with probabilities that are the inverse of those set below. This behaviour can be overridden by adding observations for the same entity's other states.", + + "data": { + "name": "[%key:common::config_flow::data::name%]", + "entity_id": "Entity", + "to_state": "To state", + "prob_given_true": "Probability when {parent_sensor_name} is {device_class_on}", + "prob_given_false": "Probability when {parent_sensor_name} is {device_class_off}" + }, + "data_description": { + "name": "This name will be used for to identify this observation for editing in the future.", + "entity_id": "An entity that is correlated with `{parent_sensor_name}`.", + "to_state": "The state of the sensor for which the observation will be considered `True`.", + "prob_given_true": "The estimated probability or proportion of time this observation is `True` while `{parent_sensor_name}` is, or should be, `{device_class_on}`.", + "prob_given_false": "The estimated probability or proportion of time this observation is `True` while `{parent_sensor_name}` is, or should be, `{device_class_off}`." + } + }, + "numeric_state": { + "title": "[%key:component::bayesian::config_subentries::observation::step::state::title%]", + "description": "Add an observation which evaluates to `True` when a numeric sensor is within a chosen range.", + "data": { + "name": "[%key:common::config_flow::data::name%]", + "entity_id": "[%key:component::bayesian::config_subentries::observation::step::state::data::entity_id%]", + "above": "Above", + "below": "Below", + "prob_given_true": "[%key:component::bayesian::config_subentries::observation::step::state::data::prob_given_true%]", + "prob_given_false": "[%key:component::bayesian::config_subentries::observation::step::state::data::prob_given_false%]" + }, + "data_description": { + "name": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::name%]", + "entity_id": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::entity_id%]", + "above": "Optional - the lower end of the numeric range. Values exactly matching this will not count", + "below": "Optional - the upper end of the numeric range. Values exactly matching this will only count if more than one range is configured for the same entity.", + "prob_given_true": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::prob_given_true%]", + "prob_given_false": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::prob_given_false%]" + } + }, + "template": { + "title": "[%key:component::bayesian::config_subentries::observation::step::state::title%]", + "description": "Add a custom observation which evaluates whether a template is observed (`True`) or not (`False`).", + "data": { + "name": "[%key:common::config_flow::data::name%]", + "value_template": "Template", + "prob_given_true": "[%key:component::bayesian::config_subentries::observation::step::state::data::prob_given_true%]", + "prob_given_false": "[%key:component::bayesian::config_subentries::observation::step::state::data::prob_given_false%]" + }, + "data_description": { + "name": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::name%]", + "value_template": "A template that evaluates to `True` will update the prior accordingly, A template that returns `False` or `None` will update the prior with inverse probabilities. A template that returns an error will not update probabilities. Results are coerced into being `True` or `False`", + "prob_given_true": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::prob_given_true%]", + "prob_given_false": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::prob_given_false%]" + } + }, + "reconfigure": { + "title": "Edit observation", + "description": "[%key:component::bayesian::config_subentries::observation::step::state::description%]", + "data": { + "name": "[%key:common::config_flow::data::name%]", + "entity_id": "[%key:component::bayesian::config_subentries::observation::step::state::data::entity_id%]", + "to_state": "[%key:component::bayesian::config_subentries::observation::step::state::data::to_state%]", + "above": "[%key:component::bayesian::config_subentries::observation::step::numeric_state::data::above%]", + "below": "[%key:component::bayesian::config_subentries::observation::step::numeric_state::data::below%]", + "value_template": "[%key:component::bayesian::config_subentries::observation::step::template::data::value_template%]", + "prob_given_true": "[%key:component::bayesian::config_subentries::observation::step::state::data::prob_given_true%]", + "prob_given_false": "[%key:component::bayesian::config_subentries::observation::step::state::data::prob_given_false%]" + }, + "data_description": { + "name": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::name%]", + "entity_id": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::entity_id%]", + "to_state": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::to_state%]", + "above": "[%key:component::bayesian::config_subentries::observation::step::numeric_state::data_description::above%]", + "below": "[%key:component::bayesian::config_subentries::observation::step::numeric_state::data_description::below%]", + "value_template": "[%key:component::bayesian::config_subentries::observation::step::template::data_description::value_template%]", + "prob_given_true": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::prob_given_true%]", + "prob_given_false": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::prob_given_false%]" + } + } + }, + "initiate_flow": { + "user": "[%key:component::bayesian::config_subentries::observation::step::user::title%]" + }, + "entry_type": "Observation", + "error": { + "equal_probabilities": "[%key:component::bayesian::config::error::equal_probabilities%]", + "extreme_prob_given_error": "[%key:component::bayesian::config::error::extreme_prob_given_error%]", + "above_below": "[%key:component::bayesian::config::error::above_below%]", + "above_or_below": "[%key:component::bayesian::config::error::above_or_below%]", + "overlapping_ranges": "[%key:component::bayesian::config::error::overlapping_ranges%]" + }, + "abort": { + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + } + } + }, + "selector": { + "binary_sensor_device_class": { + "options": { + "battery": "[%key:component::binary_sensor::entity_component::battery::name%]", + "battery_charging": "[%key:component::binary_sensor::entity_component::battery_charging::name%]", + "carbon_monoxide": "[%key:component::binary_sensor::entity_component::carbon_monoxide::name%]", + "cold": "[%key:component::binary_sensor::entity_component::cold::name%]", + "connectivity": "[%key:component::binary_sensor::entity_component::connectivity::name%]", + "door": "[%key:component::binary_sensor::entity_component::door::name%]", + "garage_door": "[%key:component::binary_sensor::entity_component::garage_door::name%]", + "gas": "[%key:component::binary_sensor::entity_component::gas::name%]", + "heat": "[%key:component::binary_sensor::entity_component::heat::name%]", + "light": "[%key:component::binary_sensor::entity_component::light::name%]", + "lock": "[%key:component::binary_sensor::entity_component::lock::name%]", + "moisture": "[%key:component::binary_sensor::entity_component::moisture::name%]", + "motion": "[%key:component::binary_sensor::entity_component::motion::name%]", + "moving": "[%key:component::binary_sensor::entity_component::moving::name%]", + "occupancy": "[%key:component::binary_sensor::entity_component::occupancy::name%]", + "opening": "[%key:component::binary_sensor::entity_component::opening::name%]", + "plug": "[%key:component::binary_sensor::entity_component::plug::name%]", + "power": "[%key:component::binary_sensor::entity_component::power::name%]", + "presence": "[%key:component::binary_sensor::entity_component::presence::name%]", + "problem": "[%key:component::binary_sensor::entity_component::problem::name%]", + "running": "[%key:component::binary_sensor::entity_component::running::name%]", + "safety": "[%key:component::binary_sensor::entity_component::safety::name%]", + "smoke": "[%key:component::binary_sensor::entity_component::smoke::name%]", + "sound": "[%key:component::binary_sensor::entity_component::sound::name%]", + "tamper": "[%key:component::binary_sensor::entity_component::tamper::name%]", + "update": "[%key:component::binary_sensor::entity_component::update::name%]", + "vibration": "[%key:component::binary_sensor::entity_component::vibration::name%]", + "window": "[%key:component::binary_sensor::entity_component::window::name%]" + } + } } } diff --git a/homeassistant/components/co2signal/coordinator.py b/homeassistant/components/co2signal/coordinator.py index be2036292e3d79..f29f3c72f1ffda 100644 --- a/homeassistant/components/co2signal/coordinator.py +++ b/homeassistant/components/co2signal/coordinator.py @@ -6,10 +6,10 @@ import logging from aioelectricitymaps import ( - CarbonIntensityResponse, ElectricityMaps, ElectricityMapsError, ElectricityMapsInvalidTokenError, + HomeAssistantCarbonIntensityResponse, ) from homeassistant.config_entries import ConfigEntry @@ -25,7 +25,7 @@ type CO2SignalConfigEntry = ConfigEntry[CO2SignalCoordinator] -class CO2SignalCoordinator(DataUpdateCoordinator[CarbonIntensityResponse]): +class CO2SignalCoordinator(DataUpdateCoordinator[HomeAssistantCarbonIntensityResponse]): """Data update coordinator.""" config_entry: CO2SignalConfigEntry @@ -51,7 +51,7 @@ def entry_id(self) -> str: """Return entry ID.""" return self.config_entry.entry_id - async def _async_update_data(self) -> CarbonIntensityResponse: + async def _async_update_data(self) -> HomeAssistantCarbonIntensityResponse: """Fetch the latest data from the source.""" try: diff --git a/homeassistant/components/co2signal/helpers.py b/homeassistant/components/co2signal/helpers.py index 3feabef2fdded4..b76e990c8f39b7 100644 --- a/homeassistant/components/co2signal/helpers.py +++ b/homeassistant/components/co2signal/helpers.py @@ -5,8 +5,12 @@ from collections.abc import Mapping from typing import Any -from aioelectricitymaps import ElectricityMaps -from aioelectricitymaps.models import CarbonIntensityResponse +from aioelectricitymaps import ( + CoordinatesRequest, + ElectricityMaps, + HomeAssistantCarbonIntensityResponse, + ZoneRequest, +) from homeassistant.const import CONF_COUNTRY_CODE, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant @@ -16,14 +20,16 @@ async def fetch_latest_carbon_intensity( hass: HomeAssistant, em: ElectricityMaps, config: Mapping[str, Any], -) -> CarbonIntensityResponse: +) -> HomeAssistantCarbonIntensityResponse: """Fetch the latest carbon intensity based on country code or location coordinates.""" - if CONF_COUNTRY_CODE in config: - return await em.latest_carbon_intensity_by_country_code( - code=config[CONF_COUNTRY_CODE] - ) - - return await em.latest_carbon_intensity_by_coordinates( + request: CoordinatesRequest | ZoneRequest = CoordinatesRequest( lat=config.get(CONF_LATITUDE, hass.config.latitude), lon=config.get(CONF_LONGITUDE, hass.config.longitude), ) + + if CONF_COUNTRY_CODE in config: + request = ZoneRequest( + zone=config[CONF_COUNTRY_CODE], + ) + + return await em.carbon_intensity_for_home_assistant(request) diff --git a/homeassistant/components/co2signal/manifest.json b/homeassistant/components/co2signal/manifest.json index ff6d5bdb18b8c6..106fd0686af52e 100644 --- a/homeassistant/components/co2signal/manifest.json +++ b/homeassistant/components/co2signal/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aioelectricitymaps"], - "requirements": ["aioelectricitymaps==0.4.0"] + "requirements": ["aioelectricitymaps==1.1.1"] } diff --git a/homeassistant/components/co2signal/sensor.py b/homeassistant/components/co2signal/sensor.py index a8e962532b8ea2..9cf5ae4c9a7972 100644 --- a/homeassistant/components/co2signal/sensor.py +++ b/homeassistant/components/co2signal/sensor.py @@ -5,7 +5,7 @@ from collections.abc import Callable from dataclasses import dataclass -from aioelectricitymaps.models import CarbonIntensityResponse +from aioelectricitymaps import HomeAssistantCarbonIntensityResponse from homeassistant.components.sensor import ( SensorEntity, @@ -28,10 +28,10 @@ class CO2SensorEntityDescription(SensorEntityDescription): # For backwards compat, allow description to override unique ID key to use unique_id: str | None = None - unit_of_measurement_fn: Callable[[CarbonIntensityResponse], str | None] | None = ( - None - ) - value_fn: Callable[[CarbonIntensityResponse], float | None] + unit_of_measurement_fn: ( + Callable[[HomeAssistantCarbonIntensityResponse], str | None] | None + ) = None + value_fn: Callable[[HomeAssistantCarbonIntensityResponse], float | None] SENSORS = ( diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index dc35c47ff4a861..91c1e619d0b079 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -30,9 +30,8 @@ MAX_WS_RECONNECT_TIME = 600 SCAN_INTERVAL = timedelta(minutes=8) DEFAULT_RECONNECT_TIME = 2 # Define a default reconnect time -PONG_TIMEOUT = timedelta(seconds=90) -PING_INTERVAL = timedelta(seconds=10) -PING_TIMEOUT = timedelta(seconds=5) +PING_INTERVAL = 60 + type AutomowerConfigEntry = ConfigEntry[AutomowerDataUpdateCoordinator] @@ -206,7 +205,7 @@ async def _pong_watchdog(self) -> None: self.websocket_alive = await self.api.send_empty_message() _LOGGER.debug("Ping result: %s", self.websocket_alive) - await asyncio.sleep(60) + await asyncio.sleep(PING_INTERVAL) _LOGGER.debug("Websocket alive %s", self.websocket_alive) if not self.websocket_alive: _LOGGER.debug("No pong received → restart polling") diff --git a/homeassistant/components/iaqualink/manifest.json b/homeassistant/components/iaqualink/manifest.json index a0a38e773f788f..6e8ce312ad02c8 100644 --- a/homeassistant/components/iaqualink/manifest.json +++ b/homeassistant/components/iaqualink/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/iaqualink", "iot_class": "cloud_polling", "loggers": ["iaqualink"], - "requirements": ["iaqualink==0.6.0", "h2==4.2.0"], + "requirements": ["iaqualink==0.6.0", "h2==4.3.0"], "single_config_entry": true } diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index 597a3372400945..218b0e9305b6b6 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -181,6 +181,18 @@ "0": "mdi:volume-off" } }, + "volume_speak": { + "default": "mdi:volume-high", + "state": { + "0": "mdi:volume-off" + } + }, + "volume_doorbell": { + "default": "mdi:volume-high", + "state": { + "0": "mdi:volume-off" + } + }, "alarm_volume": { "default": "mdi:volume-high", "state": { @@ -306,6 +318,9 @@ }, "pre_record_battery_stop": { "default": "mdi:history" + }, + "silent_time": { + "default": "mdi:volume-off" } }, "select": { diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 8f39fcd4880963..721b14e9daf405 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -150,6 +150,30 @@ class ReolinkChimeNumberEntityDescription( value=lambda api, ch: api.volume(ch), method=lambda api, ch, value: api.set_volume(ch, volume=int(value)), ), + ReolinkNumberEntityDescription( + key="volume_speak", + cmd_key="GetAudioCfg", + translation_key="volume_speak", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api, ch: api.supported(ch, "volume_speek"), + value=lambda api, ch: api.volume_speek(ch), + method=lambda api, ch, value: api.set_volume(ch, volume_speek=int(value)), + ), + ReolinkNumberEntityDescription( + key="volume_doorbell", + cmd_key="GetAudioCfg", + translation_key="volume_doorbell", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api, ch: api.supported(ch, "volume_doorbell"), + value=lambda api, ch: api.volume_doorbell(ch), + method=lambda api, ch, value: api.set_volume(ch, volume_doorbell=int(value)), + ), ReolinkNumberEntityDescription( key="guard_return_time", cmd_key="GetPtzGuard", @@ -780,6 +804,19 @@ class ReolinkChimeNumberEntityDescription( value=lambda chime: chime.volume, method=lambda chime, value: chime.set_option(volume=int(value)), ), + ReolinkChimeNumberEntityDescription( + key="silent_time", + cmd_key="609", + translation_key="silent_time", + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, + native_step=1, + native_min_value=0, + native_max_value=720, + native_unit_of_measurement=UnitOfTime.MINUTES, + value=lambda chime: int(chime.silent_time / 60), + method=lambda chime, value: chime.set_silent_time(time=int(value * 60)), + ), ) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 7e8bf94eeaebb6..b0a969f53d5ed3 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -541,6 +541,12 @@ "volume": { "name": "Volume" }, + "volume_speak": { + "name": "Speak volume" + }, + "volume_doorbell": { + "name": "Doorbell volume" + }, "alarm_volume": { "name": "Alarm volume" }, @@ -660,6 +666,9 @@ }, "pre_record_battery_stop": { "name": "Pre-recording stop battery level" + }, + "silent_time": { + "name": "Silent time" } }, "select": { diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py index 0ea5fc60472a61..81e633717176bb 100644 --- a/homeassistant/components/rest_command/__init__.py +++ b/homeassistant/components/rest_command/__init__.py @@ -12,6 +12,7 @@ import voluptuous as vol from homeassistant.const import ( + CONF_AUTHENTICATION, CONF_HEADERS, CONF_METHOD, CONF_PASSWORD, @@ -20,6 +21,8 @@ CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL, + HTTP_BASIC_AUTHENTICATION, + HTTP_DIGEST_AUTHENTICATION, SERVICE_RELOAD, ) from homeassistant.core import ( @@ -56,6 +59,9 @@ vol.Lower, vol.In(SUPPORT_REST_METHODS) ), vol.Optional(CONF_HEADERS): vol.Schema({cv.string: cv.template}), + vol.Optional(CONF_AUTHENTICATION): vol.In( + [HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION] + ), vol.Inclusive(CONF_USERNAME, "authentication"): cv.string, vol.Inclusive(CONF_PASSWORD, "authentication"): cv.string, vol.Optional(CONF_PAYLOAD): cv.template, @@ -109,10 +115,14 @@ def async_register_rest_command(name: str, command_config: dict[str, Any]) -> No template_url = command_config[CONF_URL] auth = None + digest_middleware = None if CONF_USERNAME in command_config: username = command_config[CONF_USERNAME] password = command_config.get(CONF_PASSWORD, "") - auth = aiohttp.BasicAuth(username, password=password) + if command_config.get(CONF_AUTHENTICATION) == HTTP_DIGEST_AUTHENTICATION: + digest_middleware = aiohttp.DigestAuthMiddleware(username, password) + else: + auth = aiohttp.BasicAuth(username, password=password) template_payload = None if CONF_PAYLOAD in command_config: @@ -155,12 +165,22 @@ async def async_service_handler(service: ServiceCall) -> ServiceResponse: ) try: + # Prepare request kwargs + request_kwargs = { + "data": payload, + "headers": headers or None, + "timeout": timeout, + } + + # Add authentication + if auth is not None: + request_kwargs["auth"] = auth + elif digest_middleware is not None: + request_kwargs["middlewares"] = (digest_middleware,) + async with getattr(websession, method)( request_url, - data=payload, - auth=auth, - headers=headers or None, - timeout=timeout, + **request_kwargs, ) as response: if response.status < HTTPStatus.BAD_REQUEST: _LOGGER.debug( diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 9f5e6a9190553c..abd07d89db6472 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["zha==0.0.69"], + "requirements": ["zha==0.0.70"], "usb": [ { "vid": "10C4", diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 1c9454ec0a068b..096fd591fb7ae5 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -654,6 +654,9 @@ }, "reset_alarm": { "name": "Reset alarm" + }, + "calibrate_valve": { + "name": "Calibrate valve" } }, "climate": { @@ -1185,6 +1188,33 @@ }, "tilt_position_percentage_after_move_to_level": { "name": "Tilt position percentage after move to level" + }, + "display_on_time": { + "name": "Display on-time" + }, + "closing_duration": { + "name": "Closing duration" + }, + "opening_duration": { + "name": "Opening duration" + }, + "long_press_duration": { + "name": "Long press duration" + }, + "motor_start_delay": { + "name": "Motor start delay" + }, + "max_brightness": { + "name": "Maximum brightness" + }, + "min_brightness": { + "name": "Minimum brightness" + }, + "reporting_interval": { + "name": "Reporting interval" + }, + "sensitivity": { + "name": "Sensitivity" } }, "select": { @@ -1424,6 +1454,21 @@ }, "switch_actions": { "name": "Switch actions" + }, + "ctrl_sequence_of_oper": { + "name": "Control sequence" + }, + "displayed_temperature": { + "name": "Displayed temperature" + }, + "calibration_mode": { + "name": "Calibration mode" + }, + "mode_switch": { + "name": "Mode switch" + }, + "phase": { + "name": "Phase" } }, "sensor": { @@ -1806,6 +1851,15 @@ }, "opening": { "name": "Opening" + }, + "operating_mode": { + "name": "Operating mode" + }, + "valve_adapt_status": { + "name": "Valve adaptation status" + }, + "motor_state": { + "name": "Motor state" } }, "switch": { @@ -2036,6 +2090,21 @@ }, "frient_com_2": { "name": "COM 2" + }, + "window_open": { + "name": "Window open" + }, + "turn_on_led_when_off": { + "name": "Turn on LED when off" + }, + "turn_on_led_when_on": { + "name": "Turn on LED when on" + }, + "dimmer_mode": { + "name": "Dimmer mode" + }, + "flip_indicator_light": { + "name": "Flip indicator light" } } } diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 19fb5491465ae9..65c3d68ad0c119 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -87,6 +87,7 @@ "baf", "balboa", "bang_olufsen", + "bayesian", "blebox", "blink", "blue_current", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index fc4cf30556b895..5e3090eaaf4efe 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -665,6 +665,12 @@ "integration_type": "virtual", "supported_by": "whirlpool" }, + "bayesian": { + "name": "Bayesian", + "integration_type": "service", + "config_flow": true, + "iot_class": "calculated" + }, "bbox": { "name": "Bbox", "integration_type": "hub", @@ -7764,12 +7770,6 @@ } }, "helper": { - "bayesian": { - "name": "Bayesian", - "integration_type": "helper", - "config_flow": false, - "iot_class": "local_polling" - }, "counter": { "integration_type": "helper", "config_flow": false diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index c2ebddf8012f69..e3dda5d32f3b16 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -373,6 +373,17 @@ def entity_id(value: Any) -> str: raise vol.Invalid(f"Entity ID {value} is an invalid entity ID") +def strict_entity_id(value: Any) -> str: + """Validate Entity ID, strictly.""" + if not isinstance(value, str): + raise vol.Invalid(f"Entity ID {value} is not a string") + + if valid_entity_id(value): + return value + + raise vol.Invalid(f"Entity ID {value} is not a valid entity ID") + + def entity_id_or_uuid(value: Any) -> str: """Validate Entity specified by entity_id or uuid.""" with contextlib.suppress(vol.Invalid): @@ -1292,6 +1303,15 @@ def platform_only_config_schema(domain: str) -> Callable[[dict], dict]: PLATFORM_SCHEMA_BASE = PLATFORM_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA) + +TARGET_FIELDS: VolDictType = { + vol.Optional(ATTR_ENTITY_ID): vol.All(ensure_list, [strict_entity_id]), + vol.Optional(ATTR_DEVICE_ID): vol.All(ensure_list, [str]), + vol.Optional(ATTR_AREA_ID): vol.All(ensure_list, [str]), + vol.Optional(ATTR_FLOOR_ID): vol.All(ensure_list, [str]), + vol.Optional(ATTR_LABEL_ID): vol.All(ensure_list, [str]), +} + ENTITY_SERVICE_FIELDS: VolDictType = { # Either accept static entity IDs, a single dynamic template or a mixed list # of static and dynamic templates. While this could be solved with a single diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index dc69916a72801d..5427c220c02f51 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -828,7 +828,7 @@ def selector_serializer(schema: Any) -> Any: # noqa: C901 return {"type": "string", "enum": options} if isinstance(schema, selector.TargetSelector): - return convert(cv.TARGET_SERVICE_FIELDS) + return convert(cv.TARGET_FIELDS) if isinstance(schema, selector.TemplateSelector): return {"type": "string", "format": "jinja2"} diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5112b68c5472c4..159ad63c6490c5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -45,7 +45,7 @@ ifaddr==0.2.0 Jinja2==3.1.6 lru-dict==1.3.0 mutagen==1.47.0 -orjson==3.11.2 +orjson==3.11.3 packaging>=23.1 paho-mqtt==2.1.0 Pillow==11.3.0 diff --git a/pyproject.toml b/pyproject.toml index 98586e97595f8b..92df33f7b72503 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,7 +61,7 @@ dependencies = [ "Pillow==11.3.0", "propcache==0.3.2", "pyOpenSSL==25.1.0", - "orjson==3.11.2", + "orjson==3.11.3", "packaging>=23.1", "psutil-home-assistant==0.0.1", "python-slugify==8.0.4", diff --git a/requirements.txt b/requirements.txt index cd288335aadfb2..d68eb890c43234 100644 --- a/requirements.txt +++ b/requirements.txt @@ -33,7 +33,7 @@ cryptography==45.0.3 Pillow==11.3.0 propcache==0.3.2 pyOpenSSL==25.1.0 -orjson==3.11.2 +orjson==3.11.3 packaging>=23.1 psutil-home-assistant==0.0.1 python-slugify==8.0.4 diff --git a/requirements_all.txt b/requirements_all.txt index 0eb145ea908aae..ee8e847cd2118a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -179,7 +179,7 @@ aioacaia==0.1.14 aioairq==0.4.6 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.7.1 +aioairzone-cloud==0.7.2 # homeassistant.components.airzone aioairzone==1.0.0 @@ -241,7 +241,7 @@ aioeagle==1.1.0 aioecowitt==2025.3.1 # homeassistant.components.co2signal -aioelectricitymaps==0.4.0 +aioelectricitymaps==1.1.1 # homeassistant.components.emonitor aioemonitor==1.0.5 @@ -1112,7 +1112,7 @@ gstreamer-player==1.1.2 guppy3==3.1.5 # homeassistant.components.iaqualink -h2==4.2.0 +h2==4.3.0 # homeassistant.components.ffmpeg ha-ffmpeg==3.2.2 @@ -3198,7 +3198,7 @@ zeroconf==0.147.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.69 +zha==0.0.70 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f9e032d5b9118f..d71f5611b3c6c4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -167,7 +167,7 @@ aioacaia==0.1.14 aioairq==0.4.6 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.7.1 +aioairzone-cloud==0.7.2 # homeassistant.components.airzone aioairzone==1.0.0 @@ -229,7 +229,7 @@ aioeagle==1.1.0 aioecowitt==2025.3.1 # homeassistant.components.co2signal -aioelectricitymaps==0.4.0 +aioelectricitymaps==1.1.1 # homeassistant.components.emonitor aioemonitor==1.0.5 @@ -973,7 +973,7 @@ gstreamer-player==1.1.2 guppy3==3.1.5 # homeassistant.components.iaqualink -h2==4.2.0 +h2==4.3.0 # homeassistant.components.ffmpeg ha-ffmpeg==3.2.2 @@ -2642,7 +2642,7 @@ zeroconf==0.147.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.69 +zha==0.0.70 # homeassistant.components.zwave_js zwave-js-server-python==0.67.1 diff --git a/tests/components/bayesian/test_binary_sensor.py b/tests/components/bayesian/test_binary_sensor.py index a8723ae5d3006a..b0d81af228cb8a 100644 --- a/tests/components/bayesian/test_binary_sensor.py +++ b/tests/components/bayesian/test_binary_sensor.py @@ -7,7 +7,8 @@ import pytest from homeassistant import config as hass_config -from homeassistant.components.bayesian import DOMAIN, binary_sensor as bayesian +from homeassistant.components.bayesian import binary_sensor as bayesian +from homeassistant.components.bayesian.const import DOMAIN from homeassistant.components.homeassistant import ( DOMAIN as HA_DOMAIN, SERVICE_UPDATE_ENTITY, diff --git a/tests/components/bayesian/test_config_flow.py b/tests/components/bayesian/test_config_flow.py new file mode 100644 index 00000000000000..0911113a22a3f3 --- /dev/null +++ b/tests/components/bayesian/test_config_flow.py @@ -0,0 +1,1211 @@ +"""Test the Config flow for the Bayesian integration.""" + +from __future__ import annotations + +from types import MappingProxyType +from unittest.mock import patch + +import pytest +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.bayesian.config_flow import ( + OBSERVATION_SELECTOR, + USER, + ObservationTypes, + OptionsFlowSteps, +) +from homeassistant.components.bayesian.const import ( + CONF_P_GIVEN_F, + CONF_P_GIVEN_T, + CONF_PRIOR, + CONF_PROBABILITY_THRESHOLD, + CONF_TO_STATE, + DOMAIN, +) +from homeassistant.config_entries import ( + ConfigEntry, + ConfigSubentry, + ConfigSubentryDataWithId, +) +from homeassistant.const import ( + CONF_ABOVE, + CONF_BELOW, + CONF_DEVICE_CLASS, + CONF_ENTITY_ID, + CONF_NAME, + CONF_PLATFORM, + CONF_STATE, + CONF_VALUE_TEMPLATE, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_config_flow_step_user(hass: HomeAssistant) -> None: + """Test the config flow with an example.""" + with patch( + "homeassistant.components.bayesian.async_setup_entry", return_value=True + ): + # Open config flow + result0 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result0["step_id"] == USER + assert result0["type"] is FlowResultType.FORM + assert ( + result0["description_placeholders"]["url"] + == "https://www.home-assistant.io/integrations/bayesian/" + ) + + # Enter basic settings + result1 = await hass.config_entries.flow.async_configure( + result0["flow_id"], + { + CONF_NAME: "Office occupied", + CONF_PROBABILITY_THRESHOLD: 50, + CONF_PRIOR: 15, + CONF_DEVICE_CLASS: "occupancy", + }, + ) + await hass.async_block_till_done() + + # We move on to the next step - the observation selector + assert result1["step_id"] == OBSERVATION_SELECTOR + assert result1["type"] is FlowResultType.MENU + assert result1["flow_id"] is not None + + +async def test_subentry_flow(hass: HomeAssistant) -> None: + """Test the subentry flow with a full example.""" + with patch( + "homeassistant.components.bayesian.async_setup_entry", return_value=True + ) as mock_setup_entry: + # Set up the initial config entry as a mock to isolate testing of subentry flows + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_NAME: "Office occupied", + CONF_PROBABILITY_THRESHOLD: 50, + CONF_PRIOR: 15, + CONF_DEVICE_CLASS: "occupancy", + }, + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Open subentry flow + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "observation"), + context={"source": config_entries.SOURCE_USER}, + ) + # Confirm the next page is the observation type selector + assert result["step_id"] == "user" + assert result["type"] is FlowResultType.MENU + assert result["flow_id"] is not None + + # Set up a numeric state observation first + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {"next_step_id": str(ObservationTypes.NUMERIC_STATE)} + ) + await hass.async_block_till_done() + + assert result["step_id"] == str(ObservationTypes.NUMERIC_STATE) + assert result["type"] is FlowResultType.FORM + + # Set up a numeric range with only 'Above' + # Also indirectly tests the conversion of proabilities to fractions + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_ENTITY_ID: "sensor.office_illuminance_lux", + CONF_ABOVE: 40, + CONF_P_GIVEN_T: 85, + CONF_P_GIVEN_F: 45, + CONF_NAME: "Office is bright", + }, + ) + await hass.async_block_till_done() + + # Open another subentry flow + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "observation"), + context={"source": config_entries.SOURCE_USER}, + ) + assert result["step_id"] == "user" + assert result["type"] is FlowResultType.MENU + assert result["flow_id"] is not None + + # Add a state observation + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {"next_step_id": str(ObservationTypes.STATE)} + ) + await hass.async_block_till_done() + + assert result["step_id"] == str(ObservationTypes.STATE) + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_ENTITY_ID: "sensor.work_laptop", + CONF_TO_STATE: "on", + CONF_P_GIVEN_T: 60, + CONF_P_GIVEN_F: 20, + CONF_NAME: "Work laptop on network", + }, + ) + await hass.async_block_till_done() + + # Open another subentry flow + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "observation"), + context={"source": config_entries.SOURCE_USER}, + ) + assert result["step_id"] == "user" + assert result["type"] is FlowResultType.MENU + assert result["flow_id"] is not None + + # Lastly, add a template observation + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {"next_step_id": str(ObservationTypes.TEMPLATE)} + ) + await hass.async_block_till_done() + + assert result["step_id"] == str(ObservationTypes.TEMPLATE) + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_VALUE_TEMPLATE: """ +{% set current_time = now().time() %} +{% set start_time = strptime("07:00", "%H:%M").time() %} +{% set end_time = strptime("18:30", "%H:%M").time() %} +{% if start_time <= current_time <= end_time %} +True +{% else %} +False +{% endif %} + """, + CONF_P_GIVEN_T: 45, + CONF_P_GIVEN_F: 5, + CONF_NAME: "Daylight hours", + }, + ) + + observations = [ + dict(subentry.data) for subentry in config_entry.subentries.values() + ] + # assert config_entry["version"] == 1 + assert observations == [ + { + CONF_PLATFORM: str(ObservationTypes.NUMERIC_STATE), + CONF_ENTITY_ID: "sensor.office_illuminance_lux", + CONF_ABOVE: 40, + CONF_P_GIVEN_T: 0.85, + CONF_P_GIVEN_F: 0.45, + CONF_NAME: "Office is bright", + }, + { + CONF_PLATFORM: str(ObservationTypes.STATE), + CONF_ENTITY_ID: "sensor.work_laptop", + CONF_TO_STATE: "on", + CONF_P_GIVEN_T: 0.6, + CONF_P_GIVEN_F: 0.2, + CONF_NAME: "Work laptop on network", + }, + { + CONF_PLATFORM: str(ObservationTypes.TEMPLATE), + CONF_VALUE_TEMPLATE: '{% set current_time = now().time() %}\n{% set start_time = strptime("07:00", "%H:%M").time() %}\n{% set end_time = strptime("18:30", "%H:%M").time() %}\n{% if start_time <= current_time <= end_time %}\nTrue\n{% else %}\nFalse\n{% endif %}', + CONF_P_GIVEN_T: 0.45, + CONF_P_GIVEN_F: 0.05, + CONF_NAME: "Daylight hours", + }, + ] + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_single_state_observation(hass: HomeAssistant) -> None: + """Test a Bayesian sensor with just one state observation added. + + This test combines the config flow for a single state observation. + """ + + with patch( + "homeassistant.components.bayesian.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["step_id"] == USER + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: "Anyone home", + CONF_PROBABILITY_THRESHOLD: 50, + CONF_PRIOR: 66, + CONF_DEVICE_CLASS: "occupancy", + }, + ) + await hass.async_block_till_done() + + # Confirm the next step is the menu + assert result["step_id"] == OBSERVATION_SELECTOR + assert result["type"] is FlowResultType.MENU + assert result["flow_id"] is not None + assert result["menu_options"] == ["state", "numeric_state", "template"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": str(ObservationTypes.STATE)} + ) + await hass.async_block_till_done() + + assert result["step_id"] == str(ObservationTypes.STATE) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ENTITY_ID: "sensor.kitchen_occupancy", + CONF_TO_STATE: "on", + CONF_P_GIVEN_T: 40, + CONF_P_GIVEN_F: 0.5, + CONF_NAME: "Kitchen Motion", + }, + ) + + assert result["step_id"] == OBSERVATION_SELECTOR + assert result["type"] is FlowResultType.MENU + assert result["flow_id"] is not None + assert result["menu_options"] == [ + "state", + "numeric_state", + "template", + "finish", + ] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "finish"} + ) + await hass.async_block_till_done() + + entry_id = result["result"].entry_id + config_entry = hass.config_entries.async_get_entry(entry_id) + assert config_entry is not None + assert type(config_entry) is ConfigEntry + assert config_entry.version == 1 + assert config_entry.options == { + CONF_NAME: "Anyone home", + CONF_PROBABILITY_THRESHOLD: 0.5, + CONF_PRIOR: 0.66, + CONF_DEVICE_CLASS: "occupancy", + } + assert len(config_entry.subentries) == 1 + assert list(config_entry.subentries.values())[0].data == { + CONF_PLATFORM: CONF_STATE, + CONF_ENTITY_ID: "sensor.kitchen_occupancy", + CONF_TO_STATE: "on", + CONF_P_GIVEN_T: 0.4, + CONF_P_GIVEN_F: 0.005, + CONF_NAME: "Kitchen Motion", + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_single_numeric_state_observation(hass: HomeAssistant) -> None: + """Test a Bayesian sensor with just one numeric_state observation added. + + Combines the config flow and the options flow for a single numeric_state observation. + """ + + with patch( + "homeassistant.components.bayesian.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["step_id"] == USER + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: "Nice day", + CONF_PROBABILITY_THRESHOLD: 51, + CONF_PRIOR: 20, + }, + ) + await hass.async_block_till_done() + + # Confirm the next step is the menu + assert result["step_id"] == OBSERVATION_SELECTOR + assert result["type"] is FlowResultType.MENU + assert result["flow_id"] is not None + + # select numeric state observation + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": str(ObservationTypes.NUMERIC_STATE)} + ) + await hass.async_block_till_done() + + assert result["step_id"] == str(ObservationTypes.NUMERIC_STATE) + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ENTITY_ID: "sensor.outside_temperature", + CONF_ABOVE: 20, + CONF_BELOW: 35, + CONF_P_GIVEN_T: 95, + CONF_P_GIVEN_F: 8, + CONF_NAME: "20 - 35 outside", + }, + ) + assert result["step_id"] == OBSERVATION_SELECTOR + assert result["type"] is FlowResultType.MENU + assert result["flow_id"] is not None + assert result["menu_options"] == [ + "state", + "numeric_state", + "template", + "finish", + ] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "finish"} + ) + await hass.async_block_till_done() + config_entry = result["result"] + assert config_entry.options == { + CONF_NAME: "Nice day", + CONF_PROBABILITY_THRESHOLD: 0.51, + CONF_PRIOR: 0.2, + } + assert len(config_entry.subentries) == 1 + assert list(config_entry.subentries.values())[0].data == { + CONF_PLATFORM: str(ObservationTypes.NUMERIC_STATE), + CONF_ENTITY_ID: "sensor.outside_temperature", + CONF_ABOVE: 20, + CONF_BELOW: 35, + CONF_P_GIVEN_T: 0.95, + CONF_P_GIVEN_F: 0.08, + CONF_NAME: "20 - 35 outside", + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_multi_numeric_state_observation(hass: HomeAssistant) -> None: + """Test a Bayesian sensor with just more than one numeric_state observation added. + + Technically a subset of the tests in test_config_flow() but may help to + narrow down errors more quickly. + """ + + with patch( + "homeassistant.components.bayesian.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["step_id"] == USER + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: "Nice day", + CONF_PROBABILITY_THRESHOLD: 51, + CONF_PRIOR: 20, + }, + ) + await hass.async_block_till_done() + + # Confirm the next step is the menu + assert result["step_id"] == OBSERVATION_SELECTOR + assert result["type"] is FlowResultType.MENU + assert result["flow_id"] is not None + + # select numeric state observation + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": str(ObservationTypes.NUMERIC_STATE)} + ) + await hass.async_block_till_done() + + assert result["step_id"] == str(ObservationTypes.NUMERIC_STATE) + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ENTITY_ID: "sensor.outside_temperature", + CONF_ABOVE: 20, + CONF_BELOW: 35, + CONF_P_GIVEN_T: 95, + CONF_P_GIVEN_F: 8, + CONF_NAME: "20 - 35 outside", + }, + ) + + # Confirm the next step is the menu + assert result["step_id"] == OBSERVATION_SELECTOR + assert result["type"] is FlowResultType.MENU + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": str(ObservationTypes.NUMERIC_STATE)} + ) + await hass.async_block_till_done() + + # This should fail as overlapping ranges for the same entity are not allowed + current_step = result["step_id"] + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ENTITY_ID: "sensor.outside_temperature", + CONF_ABOVE: 30, + CONF_BELOW: 40, + CONF_P_GIVEN_T: 95, + CONF_P_GIVEN_F: 8, + CONF_NAME: "30 - 40 outside", + }, + ) + await hass.async_block_till_done() + assert result["errors"] == {"base": "overlapping_ranges"} + assert result["step_id"] == current_step + + # This should fail as above should always be less than below + current_step = result["step_id"] + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ENTITY_ID: "sensor.outside_temperature", + CONF_ABOVE: 40, + CONF_BELOW: 35, + CONF_P_GIVEN_T: 95, + CONF_P_GIVEN_F: 8, + CONF_NAME: "35 - 40 outside", + }, + ) + await hass.async_block_till_done() + assert result["step_id"] == current_step + assert result["errors"] == {"base": "above_below"} + + # This should work + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ENTITY_ID: "sensor.outside_temperature", + CONF_ABOVE: 35, + CONF_BELOW: 40, + CONF_P_GIVEN_T: 70, + CONF_P_GIVEN_F: 20, + CONF_NAME: "35 - 40 outside", + }, + ) + assert result["step_id"] == OBSERVATION_SELECTOR + assert result["type"] is FlowResultType.MENU + assert result["flow_id"] is not None + assert result["menu_options"] == [ + "state", + "numeric_state", + "template", + "finish", + ] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "finish"} + ) + await hass.async_block_till_done() + + config_entry = result["result"] + assert config_entry.version == 1 + assert config_entry.options == { + CONF_NAME: "Nice day", + CONF_PROBABILITY_THRESHOLD: 0.51, + CONF_PRIOR: 0.2, + } + observations = [ + dict(subentry.data) for subentry in config_entry.subentries.values() + ] + assert observations == [ + { + CONF_PLATFORM: str(ObservationTypes.NUMERIC_STATE), + CONF_ENTITY_ID: "sensor.outside_temperature", + CONF_ABOVE: 20.0, + CONF_BELOW: 35.0, + CONF_P_GIVEN_T: 0.95, + CONF_P_GIVEN_F: 0.08, + CONF_NAME: "20 - 35 outside", + }, + { + CONF_PLATFORM: str(ObservationTypes.NUMERIC_STATE), + CONF_ENTITY_ID: "sensor.outside_temperature", + CONF_ABOVE: 35.0, + CONF_BELOW: 40.0, + CONF_P_GIVEN_T: 0.7, + CONF_P_GIVEN_F: 0.2, + CONF_NAME: "35 - 40 outside", + }, + ] + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_single_template_observation(hass: HomeAssistant) -> None: + """Test a Bayesian sensor with just one template observation added. + + Technically a subset of the tests in test_config_flow() but may help to + narrow down errors more quickly. + """ + + with patch( + "homeassistant.components.bayesian.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["step_id"] == USER + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: "Paulus Home", + CONF_PROBABILITY_THRESHOLD: 90, + CONF_PRIOR: 50, + CONF_DEVICE_CLASS: "occupancy", + }, + ) + await hass.async_block_till_done() + + # Confirm the next step is the menu + assert result["step_id"] == OBSERVATION_SELECTOR + assert result["type"] is FlowResultType.MENU + assert result["flow_id"] is not None + + # Select template observation + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": str(ObservationTypes.TEMPLATE)} + ) + await hass.async_block_till_done() + + assert result["step_id"] == str(ObservationTypes.TEMPLATE) + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_VALUE_TEMPLATE: "{{is_state('device_tracker.paulus','not_home') and ((as_timestamp(now()) - as_timestamp(states.device_tracker.paulus.last_changed)) > 300)}}", + CONF_P_GIVEN_T: 5, + CONF_P_GIVEN_F: 99, + CONF_NAME: "Not seen in last 5 minutes", + }, + ) + assert result["step_id"] == OBSERVATION_SELECTOR + assert result["type"] is FlowResultType.MENU + assert result["flow_id"] is not None + assert result["menu_options"] == [ + "state", + "numeric_state", + "template", + "finish", + ] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "finish"} + ) + await hass.async_block_till_done() + config_entry = result["result"] + assert config_entry.version == 1 + assert config_entry.options == { + CONF_NAME: "Paulus Home", + CONF_PROBABILITY_THRESHOLD: 0.9, + CONF_PRIOR: 0.5, + CONF_DEVICE_CLASS: "occupancy", + } + assert len(config_entry.subentries) == 1 + assert list(config_entry.subentries.values())[0].data == { + CONF_PLATFORM: str(ObservationTypes.TEMPLATE), + CONF_VALUE_TEMPLATE: "{{is_state('device_tracker.paulus','not_home') and ((as_timestamp(now()) - as_timestamp(states.device_tracker.paulus.last_changed)) > 300)}}", + CONF_P_GIVEN_T: 0.05, + CONF_P_GIVEN_F: 0.99, + CONF_NAME: "Not seen in last 5 minutes", + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_basic_options(hass: HomeAssistant) -> None: + """Test reconfiguring the basic options using an options flow.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_NAME: "Office occupied", + CONF_PROBABILITY_THRESHOLD: 0.5, + CONF_PRIOR: 0.15, + CONF_DEVICE_CLASS: "occupancy", + }, + subentries_data=[ + ConfigSubentryDataWithId( + data=MappingProxyType( + { + CONF_PLATFORM: str(ObservationTypes.NUMERIC_STATE), + CONF_ENTITY_ID: "sensor.office_illuminance_lux", + CONF_ABOVE: 40, + CONF_P_GIVEN_T: 0.85, + CONF_P_GIVEN_F: 0.45, + CONF_NAME: "Office is bright", + } + ), + subentry_id="01JXCPHRM64Y84GQC58P5EKVHY", + subentry_type="observation", + title="Office is bright", + unique_id=None, + ) + ], + title="Office occupied", + ) + # Setup the mock config entry + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Give the sensor a real value + hass.states.async_set("sensor.office_illuminance_lux", 50) + + # Start the options flow + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + # Confirm the first page is the form for editing the basic options + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == str(OptionsFlowSteps.INIT) + + # Change all possible settings (name can be changed elsewhere in the UI) + await hass.config_entries.options.async_configure( + result["flow_id"], + { + CONF_PROBABILITY_THRESHOLD: 49, + CONF_PRIOR: 14, + CONF_DEVICE_CLASS: "presence", + }, + ) + await hass.async_block_till_done() + + # Confirm the changes stuck + assert hass.config_entries.async_get_entry(config_entry.entry_id).options == { + CONF_NAME: "Office occupied", + CONF_PROBABILITY_THRESHOLD: 0.49, + CONF_PRIOR: 0.14, + CONF_DEVICE_CLASS: "presence", + } + assert config_entry.subentries == { + "01JXCPHRM64Y84GQC58P5EKVHY": ConfigSubentry( + data=MappingProxyType( + { + CONF_PLATFORM: str(ObservationTypes.NUMERIC_STATE), + CONF_ENTITY_ID: "sensor.office_illuminance_lux", + CONF_ABOVE: 40, + CONF_P_GIVEN_T: 0.85, + CONF_P_GIVEN_F: 0.45, + CONF_NAME: "Office is bright", + } + ), + subentry_id="01JXCPHRM64Y84GQC58P5EKVHY", + subentry_type="observation", + title="Office is bright", + unique_id=None, + ) + } + + +async def test_reconfiguring_observations(hass: HomeAssistant) -> None: + """Test editing observations through options flow, once of each of the 3 types.""" + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_NAME: "Office occupied", + CONF_PROBABILITY_THRESHOLD: 0.5, + CONF_PRIOR: 0.15, + CONF_DEVICE_CLASS: "occupancy", + }, + subentries_data=[ + ConfigSubentryDataWithId( + data=MappingProxyType( + { + CONF_PLATFORM: str(ObservationTypes.NUMERIC_STATE), + CONF_ENTITY_ID: "sensor.office_illuminance_lux", + CONF_ABOVE: 40, + CONF_P_GIVEN_T: 0.85, + CONF_P_GIVEN_F: 0.45, + CONF_NAME: "Office is bright", + } + ), + subentry_id="01JXCPHRM64Y84GQC58P5EKVHY", + subentry_type="observation", + title="Office is bright", + unique_id=None, + ), + ConfigSubentryDataWithId( + data=MappingProxyType( + { + CONF_PLATFORM: str(ObservationTypes.STATE), + CONF_ENTITY_ID: "sensor.work_laptop", + CONF_TO_STATE: "on", + CONF_P_GIVEN_T: 0.6, + CONF_P_GIVEN_F: 0.2, + CONF_NAME: "Work laptop on network", + }, + ), + subentry_id="13TCPHRM64Y84GQC58P5EKTHF", + subentry_type="observation", + title="Work laptop on network", + unique_id=None, + ), + ConfigSubentryDataWithId( + data=MappingProxyType( + { + CONF_PLATFORM: str(ObservationTypes.TEMPLATE), + CONF_VALUE_TEMPLATE: '{% set current_time = now().time() %}\n{% set start_time = strptime("07:00", "%H:%M").time() %}\n{% set end_time = strptime("18:30", "%H:%M").time() %}\n{% if start_time <= current_time <= end_time %}\nTrue\n{% else %}\nFalse\n{% endif %}', + CONF_P_GIVEN_T: 0.45, + CONF_P_GIVEN_F: 0.05, + CONF_NAME: "Daylight hours", + } + ), + subentry_id="27TCPHRM64Y84GQC58P5EIES", + subentry_type="observation", + title="Daylight hours", + unique_id=None, + ), + ], + title="Office occupied", + ) + + # Set up the mock entry + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + hass.states.async_set("sensor.office_illuminance_lux", 50) + + # select a subentry for reconfiguration + result = await config_entry.start_subentry_reconfigure_flow( + hass, subentry_id="13TCPHRM64Y84GQC58P5EKTHF" + ) + await hass.async_block_till_done() + + # confirm the first page is the form for editing the observation + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["description_placeholders"]["parent_sensor_name"] == "Office occupied" + assert result["description_placeholders"]["device_class_on"] == "Detected" + assert result["description_placeholders"]["device_class_off"] == "Clear" + + # Edit all settings + await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_ENTITY_ID: "sensor.desktop", + CONF_TO_STATE: "on", + CONF_P_GIVEN_T: 70, + CONF_P_GIVEN_F: 12, + CONF_NAME: "Desktop on network", + }, + ) + await hass.async_block_till_done() + + # Confirm the changes to the state config + assert hass.config_entries.async_get_entry(config_entry.entry_id).options == { + CONF_NAME: "Office occupied", + CONF_PROBABILITY_THRESHOLD: 0.5, + CONF_PRIOR: 0.15, + CONF_DEVICE_CLASS: "occupancy", + } + observations = [ + dict(subentry.data) + for subentry in hass.config_entries.async_get_entry( + config_entry.entry_id + ).subentries.values() + ] + assert observations == [ + { + CONF_PLATFORM: str(ObservationTypes.NUMERIC_STATE), + CONF_ENTITY_ID: "sensor.office_illuminance_lux", + CONF_ABOVE: 40, + CONF_P_GIVEN_T: 0.85, + CONF_P_GIVEN_F: 0.45, + CONF_NAME: "Office is bright", + }, + { + CONF_PLATFORM: str(ObservationTypes.STATE), + CONF_ENTITY_ID: "sensor.desktop", + CONF_TO_STATE: "on", + CONF_P_GIVEN_T: 0.7, + CONF_P_GIVEN_F: 0.12, + CONF_NAME: "Desktop on network", + }, + { + CONF_PLATFORM: str(ObservationTypes.TEMPLATE), + CONF_VALUE_TEMPLATE: '{% set current_time = now().time() %}\n{% set start_time = strptime("07:00", "%H:%M").time() %}\n{% set end_time = strptime("18:30", "%H:%M").time() %}\n{% if start_time <= current_time <= end_time %}\nTrue\n{% else %}\nFalse\n{% endif %}', + CONF_P_GIVEN_T: 0.45, + CONF_P_GIVEN_F: 0.05, + CONF_NAME: "Daylight hours", + }, + ] + + # Next test editing a numeric_state observation + # select the subentry for reconfiguration + result = await config_entry.start_subentry_reconfigure_flow( + hass, subentry_id="01JXCPHRM64Y84GQC58P5EKVHY" + ) + await hass.async_block_till_done() + + # confirm the first page is the form for editing the observation + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + await hass.async_block_till_done() + + # Test an invalid re-configuration + # This should fail as the probabilities are equal + current_step = result["step_id"] + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_ENTITY_ID: "sensor.office_illuminance_lumens", + CONF_ABOVE: 2000, + CONF_P_GIVEN_T: 80, + CONF_P_GIVEN_F: 80, + CONF_NAME: "Office is bright", + }, + ) + await hass.async_block_till_done() + assert result["step_id"] == current_step + assert result["errors"] == {"base": "equal_probabilities"} + + # This should work + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_ENTITY_ID: "sensor.office_illuminance_lumens", + CONF_ABOVE: 2000, + CONF_P_GIVEN_T: 80, + CONF_P_GIVEN_F: 40, + CONF_NAME: "Office is bright", + }, + ) + await hass.async_block_till_done() + assert "errors" not in result + + # Confirm the changes to the state config + assert hass.config_entries.async_get_entry(config_entry.entry_id).options == { + CONF_NAME: "Office occupied", + CONF_PROBABILITY_THRESHOLD: 0.5, + CONF_PRIOR: 0.15, + CONF_DEVICE_CLASS: "occupancy", + } + observations = [ + dict(subentry.data) + for subentry in hass.config_entries.async_get_entry( + config_entry.entry_id + ).subentries.values() + ] + assert observations == [ + { + CONF_PLATFORM: str(ObservationTypes.NUMERIC_STATE), + CONF_ENTITY_ID: "sensor.office_illuminance_lumens", + CONF_ABOVE: 2000, + CONF_P_GIVEN_T: 0.8, + CONF_P_GIVEN_F: 0.4, + CONF_NAME: "Office is bright", + }, + { + CONF_PLATFORM: str(ObservationTypes.STATE), + CONF_ENTITY_ID: "sensor.desktop", + CONF_TO_STATE: "on", + CONF_P_GIVEN_T: 0.7, + CONF_P_GIVEN_F: 0.12, + CONF_NAME: "Desktop on network", + }, + { + CONF_PLATFORM: str(ObservationTypes.TEMPLATE), + CONF_VALUE_TEMPLATE: '{% set current_time = now().time() %}\n{% set start_time = strptime("07:00", "%H:%M").time() %}\n{% set end_time = strptime("18:30", "%H:%M").time() %}\n{% if start_time <= current_time <= end_time %}\nTrue\n{% else %}\nFalse\n{% endif %}', + CONF_P_GIVEN_T: 0.45, + CONF_P_GIVEN_F: 0.05, + CONF_NAME: "Daylight hours", + }, + ] + + # Next test editing a template observation + # select the subentry for reconfiguration + result = await config_entry.start_subentry_reconfigure_flow( + hass, subentry_id="27TCPHRM64Y84GQC58P5EIES" + ) + await hass.async_block_till_done() + + # confirm the first page is the form for editing the observation + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + await hass.async_block_till_done() + + await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_VALUE_TEMPLATE: """ +{% set current_time = now().time() %} +{% set start_time = strptime("07:00", "%H:%M").time() %} +{% set end_time = strptime("17:30", "%H:%M").time() %} +{% if start_time <= current_time <= end_time %} +True +{% else %} +False +{% endif %} +""", # changed the end_time + CONF_P_GIVEN_T: 55, + CONF_P_GIVEN_F: 13, + CONF_NAME: "Office hours", + }, + ) + await hass.async_block_till_done() + # Confirm the changes to the state config + assert hass.config_entries.async_get_entry(config_entry.entry_id).options == { + CONF_NAME: "Office occupied", + CONF_PROBABILITY_THRESHOLD: 0.5, + CONF_PRIOR: 0.15, + CONF_DEVICE_CLASS: "occupancy", + } + observations = [ + dict(subentry.data) + for subentry in hass.config_entries.async_get_entry( + config_entry.entry_id + ).subentries.values() + ] + assert observations == [ + { + CONF_PLATFORM: str(ObservationTypes.NUMERIC_STATE), + CONF_ENTITY_ID: "sensor.office_illuminance_lumens", + CONF_ABOVE: 2000, + CONF_P_GIVEN_T: 0.8, + CONF_P_GIVEN_F: 0.4, + CONF_NAME: "Office is bright", + }, + { + CONF_PLATFORM: str(ObservationTypes.STATE), + CONF_ENTITY_ID: "sensor.desktop", + CONF_TO_STATE: "on", + CONF_P_GIVEN_T: 0.7, + CONF_P_GIVEN_F: 0.12, + CONF_NAME: "Desktop on network", + }, + { + CONF_PLATFORM: str(ObservationTypes.TEMPLATE), + CONF_VALUE_TEMPLATE: '{% set current_time = now().time() %}\n{% set start_time = strptime("07:00", "%H:%M").time() %}\n{% set end_time = strptime("17:30", "%H:%M").time() %}\n{% if start_time <= current_time <= end_time %}\nTrue\n{% else %}\nFalse\n{% endif %}', + CONF_P_GIVEN_T: 0.55, + CONF_P_GIVEN_F: 0.13, + CONF_NAME: "Office hours", + }, + ] + + +async def test_invalid_configs(hass: HomeAssistant) -> None: + """Test that invalid configs are refused.""" + with patch( + "homeassistant.components.bayesian.async_setup_entry", return_value=True + ): + result0 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result0["step_id"] == USER + assert result0["type"] is FlowResultType.FORM + + # priors should never be Zero, because then the sensor can never return 'on' + with pytest.raises(vol.Invalid) as excinfo: + result = await hass.config_entries.flow.async_configure( + result0["flow_id"], + { + CONF_NAME: "Office occupied", + CONF_PROBABILITY_THRESHOLD: 50, + CONF_PRIOR: 0, + }, + ) + assert CONF_PRIOR in excinfo.value.path + assert excinfo.value.error_message == "extreme_prior_error" + + # priors should never be 100% because then the sensor can never be 'off' + with pytest.raises(vol.Invalid) as excinfo: + result = await hass.config_entries.flow.async_configure( + result0["flow_id"], + { + CONF_NAME: "Office occupied", + CONF_PROBABILITY_THRESHOLD: 50, + CONF_PRIOR: 100, + }, + ) + assert CONF_PRIOR in excinfo.value.path + assert excinfo.value.error_message == "extreme_prior_error" + + # Threshold should never be 100% because then the sensor can never be 'on' + with pytest.raises(vol.Invalid) as excinfo: + result = await hass.config_entries.flow.async_configure( + result0["flow_id"], + { + CONF_NAME: "Office occupied", + CONF_PROBABILITY_THRESHOLD: 100, + CONF_PRIOR: 50, + }, + ) + assert CONF_PROBABILITY_THRESHOLD in excinfo.value.path + assert excinfo.value.error_message == "extreme_threshold_error" + + # Threshold should never be 0 because then the sensor can never be 'off' + with pytest.raises(vol.Invalid) as excinfo: + result = await hass.config_entries.flow.async_configure( + result0["flow_id"], + { + CONF_NAME: "Office occupied", + CONF_PROBABILITY_THRESHOLD: 0, + CONF_PRIOR: 50, + }, + ) + assert CONF_PROBABILITY_THRESHOLD in excinfo.value.path + assert excinfo.value.error_message == "extreme_threshold_error" + + # Now lets submit a valid config so we can test the observation flows + result = await hass.config_entries.flow.async_configure( + result0["flow_id"], + { + CONF_NAME: "Office occupied", + CONF_PROBABILITY_THRESHOLD: 50, + CONF_PRIOR: 30, + }, + ) + await hass.async_block_till_done() + assert result.get("errors") is None + + # Confirm the next step is the menu + assert result["step_id"] == OBSERVATION_SELECTOR + assert result["type"] is FlowResultType.MENU + assert result["flow_id"] is not None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": str(ObservationTypes.STATE)} + ) + await hass.async_block_till_done() + + assert result["step_id"] == str(ObservationTypes.STATE) + assert result["type"] is FlowResultType.FORM + + # Observations with a probability of 0 will create certainties + with pytest.raises(vol.Invalid) as excinfo: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ENTITY_ID: "sensor.work_laptop", + CONF_TO_STATE: "on", + CONF_P_GIVEN_T: 0, + CONF_P_GIVEN_F: 60, + CONF_NAME: "Work laptop on network", + }, + ) + assert CONF_P_GIVEN_T in excinfo.value.path + assert excinfo.value.error_message == "extreme_prob_given_error" + + # Observations with a probability of 1 will create certainties + with pytest.raises(vol.Invalid) as excinfo: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ENTITY_ID: "sensor.work_laptop", + CONF_TO_STATE: "on", + CONF_P_GIVEN_T: 60, + CONF_P_GIVEN_F: 100, + CONF_NAME: "Work laptop on network", + }, + ) + assert CONF_P_GIVEN_F in excinfo.value.path + assert excinfo.value.error_message == "extreme_prob_given_error" + + # Observations with equal probabilities have no effect + # Try with a ObservationTypes.STATE observation + current_step = result["step_id"] + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ENTITY_ID: "sensor.work_laptop", + CONF_TO_STATE: "on", + CONF_P_GIVEN_T: 60, + CONF_P_GIVEN_F: 60, + CONF_NAME: "Work laptop on network", + }, + ) + await hass.async_block_till_done() + assert result["step_id"] == current_step + assert result["errors"] == {"base": "equal_probabilities"} + + # now submit a valid result + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ENTITY_ID: "sensor.work_laptop", + CONF_TO_STATE: "on", + CONF_P_GIVEN_T: 60, + CONF_P_GIVEN_F: 70, + CONF_NAME: "Work laptop on network", + }, + ) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": str(ObservationTypes.NUMERIC_STATE)} + ) + + await hass.async_block_till_done() + current_step = result["step_id"] + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ENTITY_ID: "sensor.office_illuminance_lux", + CONF_ABOVE: 40, + CONF_P_GIVEN_T: 85, + CONF_P_GIVEN_F: 85, + CONF_NAME: "Office is bright", + }, + ) + await hass.async_block_till_done() + assert result["step_id"] == current_step + assert result["errors"] == {"base": "equal_probabilities"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ENTITY_ID: "sensor.office_illuminance_lux", + CONF_ABOVE: 40, + CONF_P_GIVEN_T: 85, + CONF_P_GIVEN_F: 10, + CONF_NAME: "Office is bright", + }, + ) + await hass.async_block_till_done() + # Try with a ObservationTypes.TEMPLATE observation + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": str(ObservationTypes.TEMPLATE)} + ) + + await hass.async_block_till_done() + current_step = result["step_id"] + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_VALUE_TEMPLATE: "{{ is_state('device_tracker.paulus', 'not_home') }}", + CONF_P_GIVEN_T: 50, + CONF_P_GIVEN_F: 50, + CONF_NAME: "Paulus not home", + }, + ) + await hass.async_block_till_done() + assert result["step_id"] == current_step + assert result["errors"] == {"base": "equal_probabilities"} diff --git a/tests/components/co2signal/__init__.py b/tests/components/co2signal/__init__.py index 394db24347b250..54d132fdc76b39 100644 --- a/tests/components/co2signal/__init__.py +++ b/tests/components/co2signal/__init__.py @@ -1,19 +1,19 @@ """Tests for the CO2 Signal integration.""" -from aioelectricitymaps.models import ( - CarbonIntensityData, - CarbonIntensityResponse, - CarbonIntensityUnit, +from aioelectricitymaps import HomeAssistantCarbonIntensityResponse +from aioelectricitymaps.models.home_assistant import ( + HomeAssistantCarbonIntensityData, + HomeAssistantCarbonIntensityUnit, ) -VALID_RESPONSE = CarbonIntensityResponse( +VALID_RESPONSE = HomeAssistantCarbonIntensityResponse( status="ok", country_code="FR", - data=CarbonIntensityData( + data=HomeAssistantCarbonIntensityData( carbon_intensity=45.98623190095805, fossil_fuel_percentage=5.461182741937103, ), - units=CarbonIntensityUnit( + units=HomeAssistantCarbonIntensityUnit( carbon_intensity="gCO2eq/kWh", ), ) diff --git a/tests/components/co2signal/conftest.py b/tests/components/co2signal/conftest.py index 680465c25377d3..48b3cf35e64c68 100644 --- a/tests/components/co2signal/conftest.py +++ b/tests/components/co2signal/conftest.py @@ -30,8 +30,7 @@ def mock_electricity_maps() -> Generator[MagicMock]: ), ): client = electricity_maps.return_value - client.latest_carbon_intensity_by_coordinates.return_value = VALID_RESPONSE - client.latest_carbon_intensity_by_country_code.return_value = VALID_RESPONSE + client.carbon_intensity_for_home_assistant.return_value = VALID_RESPONSE yield client diff --git a/tests/components/co2signal/test_config_flow.py b/tests/components/co2signal/test_config_flow.py index f8f94d44126480..6c8b6a977fabae 100644 --- a/tests/components/co2signal/test_config_flow.py +++ b/tests/components/co2signal/test_config_flow.py @@ -157,8 +157,7 @@ async def test_form_error_handling( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - electricity_maps.latest_carbon_intensity_by_coordinates.side_effect = side_effect - electricity_maps.latest_carbon_intensity_by_country_code.side_effect = side_effect + electricity_maps.carbon_intensity_for_home_assistant.side_effect = side_effect result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -172,8 +171,7 @@ async def test_form_error_handling( assert result["errors"] == {"base": err_code} # reset mock and test if now succeeds - electricity_maps.latest_carbon_intensity_by_coordinates.side_effect = None - electricity_maps.latest_carbon_intensity_by_country_code.side_effect = None + electricity_maps.carbon_intensity_for_home_assistant.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/co2signal/test_sensor.py b/tests/components/co2signal/test_sensor.py index 2154782f62d1c8..16c9763a22216e 100644 --- a/tests/components/co2signal/test_sensor.py +++ b/tests/components/co2signal/test_sensor.py @@ -62,8 +62,7 @@ async def test_sensor_update_fail( assert state.state == "45.9862319009581" assert len(electricity_maps.mock_calls) == 1 - electricity_maps.latest_carbon_intensity_by_coordinates.side_effect = error - electricity_maps.latest_carbon_intensity_by_country_code.side_effect = error + electricity_maps.carbon_intensity_for_home_assistant.side_effect = error freezer.tick(timedelta(minutes=20)) async_fire_time_changed(hass) @@ -74,8 +73,7 @@ async def test_sensor_update_fail( assert len(electricity_maps.mock_calls) == 2 # reset mock and test if entity is available again - electricity_maps.latest_carbon_intensity_by_coordinates.side_effect = None - electricity_maps.latest_carbon_intensity_by_country_code.side_effect = None + electricity_maps.carbon_intensity_for_home_assistant.side_effect = None freezer.tick(timedelta(minutes=20)) async_fire_time_changed(hass) @@ -96,10 +94,7 @@ async def test_sensor_reauth_triggered( assert (state := hass.states.get("sensor.electricity_maps_co2_intensity")) assert state.state == "45.9862319009581" - electricity_maps.latest_carbon_intensity_by_coordinates.side_effect = ( - ElectricityMapsInvalidTokenError - ) - electricity_maps.latest_carbon_intensity_by_country_code.side_effect = ( + electricity_maps.carbon_intensity_for_home_assistant.side_effect = ( ElectricityMapsInvalidTokenError ) diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index ca35d7eb70fad1..868a1d4ba9cdda 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -81,6 +81,10 @@ '0': 1, 'null': 1, }), + '609': dict({ + '0': 1, + 'null': 1, + }), 'DingDongOpt': dict({ '0': 2, 'null': 2, @@ -98,8 +102,8 @@ 'null': 1, }), 'GetAudioCfg': dict({ - '0': 2, - 'null': 2, + '0': 4, + 'null': 4, }), 'GetAutoFocus': dict({ '0': 1, diff --git a/tests/components/rest_command/test_init.py b/tests/components/rest_command/test_init.py index b9c1096f26a6a5..0c8f8a93f65bb9 100644 --- a/tests/components/rest_command/test_init.py +++ b/tests/components/rest_command/test_init.py @@ -11,6 +11,7 @@ from homeassistant.const import ( CONTENT_TYPE_JSON, CONTENT_TYPE_TEXT_PLAIN, + HTTP_DIGEST_AUTHENTICATION, SERVICE_RELOAD, ) from homeassistant.core import HomeAssistant @@ -123,6 +124,55 @@ async def test_rest_command_auth( assert len(aioclient_mock.mock_calls) == 1 +async def test_rest_command_digest_auth( + hass: HomeAssistant, + setup_component: ComponentSetup, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Call a rest command with HTTP digest authentication.""" + config = { + "digest_auth_test": { + "url": TEST_URL, + "method": "get", + "username": "test_user", + "password": "test_pass", + "authentication": HTTP_DIGEST_AUTHENTICATION, + } + } + + await setup_component(config) + + # Mock the digest auth behavior - the request will be called with DigestAuthMiddleware + with patch("aiohttp.ClientSession.get") as mock_get: + + async def async_iter_chunks(self, chunk_size): + yield b"success" + + mock_response = type( + "MockResponse", + (), + { + "status": 200, + "content_type": "text/plain", + "headers": {}, + "url": TEST_URL, + "content": type( + "MockContent", (), {"iter_chunked": async_iter_chunks} + )(), + }, + )() + mock_get.return_value.__aenter__.return_value = mock_response + + await hass.services.async_call(DOMAIN, "digest_auth_test", {}, blocking=True) + + # Verify that the request was made with DigestAuthMiddleware + assert mock_get.called + call_kwargs = mock_get.call_args[1] + assert "middlewares" in call_kwargs + assert len(call_kwargs["middlewares"]) == 1 + assert isinstance(call_kwargs["middlewares"][0], aiohttp.DigestAuthMiddleware) + + async def test_rest_command_form_data( hass: HomeAssistant, setup_component: ComponentSetup, diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 9ba93cef4caae5..6eef62a2c54524 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -1216,43 +1216,14 @@ async def test_selector_serializer( selector.StateSelector({"entity_id": "sensor.test"}) ) == {"type": "string"} target_schema = selector_serializer(selector.TargetSelector()) - target_schema["properties"]["entity_id"]["anyOf"][0][ - "enum" - ].sort() # Order is not deterministic assert target_schema == { "type": "object", "properties": { - "area_id": { - "anyOf": [ - {"type": "string", "enum": ["none"]}, - {"type": "array", "items": {"type": "string", "nullable": True}}, - ] - }, - "device_id": { - "anyOf": [ - {"type": "string", "enum": ["none"]}, - {"type": "array", "items": {"type": "string", "nullable": True}}, - ] - }, - "entity_id": { - "anyOf": [ - {"type": "string", "enum": ["all", "none"], "format": "lower"}, - {"type": "string", "nullable": True}, - {"type": "array", "items": {"type": "string"}}, - ] - }, - "floor_id": { - "anyOf": [ - {"type": "string", "enum": ["none"]}, - {"type": "array", "items": {"type": "string", "nullable": True}}, - ] - }, - "label_id": { - "anyOf": [ - {"type": "string", "enum": ["none"]}, - {"type": "array", "items": {"type": "string", "nullable": True}}, - ] - }, + "area_id": {"items": {"type": "string"}, "type": "array"}, + "device_id": {"items": {"type": "string"}, "type": "array"}, + "entity_id": {"items": {"type": "string"}, "type": "array"}, + "floor_id": {"items": {"type": "string"}, "type": "array"}, + "label_id": {"items": {"type": "string"}, "type": "array"}, }, "required": [], }