Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add preview to Threshold config & option flow #117181

Merged
merged 9 commits into from
Jun 22, 2024
Merged
60 changes: 53 additions & 7 deletions homeassistant/components/threshold/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

from collections.abc import Callable, Mapping
import logging
from typing import Any

Expand All @@ -22,7 +23,13 @@
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
from homeassistant.core import (
CALLBACK_TYPE,
Event,
EventStateChangedData,
HomeAssistant,
callback,
)
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
Expand Down Expand Up @@ -111,7 +118,6 @@ async def async_setup_entry(
async_add_entities(
[
ThresholdSensor(
hass,
entity_id,
name,
lower,
Expand Down Expand Up @@ -145,7 +151,7 @@ async def async_setup_platform(
async_add_entities(
[
ThresholdSensor(
hass, entity_id, name, lower, upper, hysteresis, device_class, None
entity_id, name, lower, upper, hysteresis, device_class, None
)
],
)
Expand All @@ -167,7 +173,6 @@ class ThresholdSensor(BinarySensorEntity):

def __init__(
self,
hass: HomeAssistant,
entity_id: str,
name: str,
lower: float | None,
Expand All @@ -178,6 +183,7 @@ def __init__(
device_info: DeviceInfo | None = None,
) -> None:
"""Initialize the Threshold sensor."""
self._preview_callback: Callable[[str, Mapping[str, Any]], None] | None = None
self._attr_unique_id = unique_id
self._attr_device_info = device_info
self._entity_id = entity_id
Expand All @@ -193,9 +199,17 @@ def __init__(
self._state: bool | None = None
self.sensor_value: float | None = None

async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
self._async_setup_sensor()

@callback
def _async_setup_sensor(self) -> None:
"""Set up the sensor and start tracking state changes."""

def _update_sensor_state() -> None:
"""Handle sensor state changes."""
if (new_state := hass.states.get(self._entity_id)) is None:
if (new_state := self.hass.states.get(self._entity_id)) is None:
return

try:
Expand All @@ -210,17 +224,26 @@ def _update_sensor_state() -> None:

self._update_state()

if self._preview_callback:
calculated_state = self._async_calculate_state()
self._preview_callback(
calculated_state.state, calculated_state.attributes
)

@callback
def async_threshold_sensor_state_listener(
event: Event[EventStateChangedData],
) -> None:
"""Handle sensor state changes."""
_update_sensor_state()
self.async_write_ha_state()

# only write state to the state machine if we are not in preview mode
if not self._preview_callback:
self.async_write_ha_state()

self.async_on_remove(
async_track_state_change_event(
hass, [entity_id], async_threshold_sensor_state_listener
self.hass, [self._entity_id], async_threshold_sensor_state_listener
)
)
_update_sensor_state()
Expand Down Expand Up @@ -305,3 +328,26 @@ def above(sensor_value: float, threshold: float) -> bool:
self._state_position = POSITION_IN_RANGE
self._state = True
return

@callback
def async_start_preview(
self,
preview_callback: Callable[[str, Mapping[str, Any]], None],
) -> CALLBACK_TYPE:
"""Render a preview."""
# abort early if there is no entity_id
# as without we can't track changes
# or if neither lower nor upper thresholds are set
if not self._entity_id or (
not hasattr(self, "_threshold_lower")
and not hasattr(self, "_threshold_upper")
):
self._attr_available = False
calculated_state = self._async_calculate_state()
preview_callback(calculated_state.state, calculated_state.attributes)
return self._call_on_remove_callbacks

self._preview_callback = preview_callback

self._async_setup_sensor()
return self._call_on_remove_callbacks
70 changes: 68 additions & 2 deletions homeassistant/components/threshold/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@

import voluptuous as vol

from homeassistant.components import websocket_api
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.const import CONF_ENTITY_ID, CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import selector
from homeassistant.helpers.schema_config_entry_flow import (
SchemaCommonFlowHandler,
Expand All @@ -17,6 +20,7 @@
SchemaFlowFormStep,
)

from .binary_sensor import ThresholdSensor
from .const import CONF_HYSTERESIS, CONF_LOWER, CONF_UPPER, DEFAULT_HYSTERESIS, DOMAIN


Expand Down Expand Up @@ -61,11 +65,15 @@ async def _validate_mode(
).extend(OPTIONS_SCHEMA.schema)

CONFIG_FLOW = {
"user": SchemaFlowFormStep(CONFIG_SCHEMA, validate_user_input=_validate_mode)
"user": SchemaFlowFormStep(
CONFIG_SCHEMA, preview="threshold", validate_user_input=_validate_mode
)
}

OPTIONS_FLOW = {
"init": SchemaFlowFormStep(OPTIONS_SCHEMA, validate_user_input=_validate_mode)
"init": SchemaFlowFormStep(
OPTIONS_SCHEMA, preview="threshold", validate_user_input=_validate_mode
)
}


Expand All @@ -79,3 +87,61 @@ def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title."""
name: str = options[CONF_NAME]
return name

@staticmethod
async def async_setup_preview(hass: HomeAssistant) -> None:
"""Set up preview WS API."""
websocket_api.async_register_command(hass, ws_start_preview)


@websocket_api.websocket_command(
{
vol.Required("type"): "threshold/start_preview",
vol.Required("flow_id"): str,
vol.Required("flow_type"): vol.Any("config_flow", "options_flow"),
vol.Required("user_input"): dict,
}
)
@callback
def ws_start_preview(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Generate a preview."""

if msg["flow_type"] == "config_flow":
entity_id = msg["user_input"][CONF_ENTITY_ID]
name = msg["user_input"][CONF_NAME]
else:
flow_status = hass.config_entries.options.async_get(msg["flow_id"])
config_entry = hass.config_entries.async_get_entry(flow_status["handler"])
if not config_entry:
raise HomeAssistantError("Config entry not found")
entity_id = config_entry.options[CONF_ENTITY_ID]
name = config_entry.options[CONF_NAME]

@callback
def async_preview_updated(state: str, attributes: Mapping[str, Any]) -> None:
"""Forward config entry state events to websocket."""
connection.send_message(
websocket_api.event_message(
msg["id"], {"attributes": attributes, "state": state}
)
)

preview_entity = ThresholdSensor(
entity_id,
name,
msg["user_input"].get(CONF_LOWER),
msg["user_input"].get(CONF_UPPER),
msg["user_input"].get(CONF_HYSTERESIS),
None,
None,
)
preview_entity.hass = hass

connection.send_result(msg["id"])
connection.subscriptions[msg["id"]] = preview_entity.async_start_preview(
async_preview_updated
)
47 changes: 47 additions & 0 deletions tests/components/threshold/snapshots/test_config_flow.ambr
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# serializer version: 1
# name: test_config_flow_preview_success[missing_entity_id]
dict({
'attributes': dict({
'friendly_name': '',
}),
'state': 'unavailable',
})
# ---
# name: test_config_flow_preview_success[missing_upper_lower]
dict({
'attributes': dict({
'friendly_name': 'Test Sensor',
}),
'state': 'unavailable',
})
# ---
# name: test_config_flow_preview_success[success]
dict({
'attributes': dict({
'entity_id': 'sensor.test_monitored',
'friendly_name': 'Test Sensor',
'hysteresis': 0.0,
'lower': 20.0,
'position': 'below',
'sensor_value': 16.0,
'type': 'lower',
'upper': None,
}),
'state': 'on',
})
# ---
# name: test_options_flow_preview
dict({
'attributes': dict({
'entity_id': 'sensor.test_monitored',
'friendly_name': 'Test Sensor',
'hysteresis': 0.0,
'lower': 20.0,
'position': 'below',
'sensor_value': 16.0,
'type': 'lower',
'upper': None,
}),
'state': 'on',
})
# ---
Loading