Compare commits

...

25 Commits

Author SHA1 Message Date
Franck Nijhof 635669278c Merge branch 'dev' into manual_trigger_entity-fix-availability 2025-03-02 21:03:23 +01:00
Niklas Neesen 8536f2b4cb Fix vicare exception for specific ventilation device type (#138343)
* fix for exception for specific ventilation device type + tests

* fix for exception for specific ventilation device type + tests

* New Testset just for fan

* update test_sensor.ambr
2025-03-02 20:57:13 +01:00
J. Nick Koston 387bf83ba8 Bump aioesphomeapi to 29.3.2 (#139653)
changelog: https://github.com/esphome/aioesphomeapi/compare/v29.3.1...v29.3.2
2025-03-02 20:53:45 +01:00
Norbert Rittel 18b0f54a3e Fix typo in outlet_2_load_off of NUT integration (#139656)
Fix typo in `outlet_2_load_off`

Fix small copy & paste error in https://github.com/home-assistant/core/pull/139044
2025-03-02 20:49:19 +01:00
Nathan Spencer f76e295204 Add fault event to balboa (#138623)
* Add fault sensor to balboa

* Use an event instead of sensor for faults

* Don't set fault initially in conftest

* Use event type per fault message code

* Set fault to None in conftest
2025-03-02 20:24:27 +01:00
Norbert Rittel e63b17cd58 Make spelling of "All-Link" consistent in Insteon integration (#139651)
"All-Link" is a fixed term in the Insteon integration that should be kept in translations. To clarify that this commit makes all occurrences in the Insteon integration consistent (plus fixing one typo).

On the other end the word "database" is sentence-cased as this can be translated, just as "record" etc.

Finally the description of the "Load All-Link database" action is made consistent using descriptive third-person singular as all other actions do.
2025-03-02 20:04:53 +01:00
martin12as 05e23f0fc7 Add nut commands to turn off/on outlet 1 & 2 (#139044)
* Update const.py

* Update strings.json

* Update homeassistant/components/nut/strings.json

Co-authored-by: tdfountain <174762217+tdfountain@users.noreply.github.com>

* Update homeassistant/components/nut/strings.json

Co-authored-by: tdfountain <174762217+tdfountain@users.noreply.github.com>

---------

Co-authored-by: tdfountain <174762217+tdfountain@users.noreply.github.com>
2025-03-02 20:00:05 +01:00
Joost Lekkerkerker fca4ef3b1e Fix scope comparison in SmartThings (#139652) 2025-03-02 19:52:37 +01:00
G Johansson 1c5eb92c9c Clean test 2025-01-13 18:26:36 +00:00
G Johansson 3337dd4ed7 Add state template test 2025-01-13 18:21:31 +00:00
G Johansson f1d21685e6 Remove availability from complex 2025-01-13 17:59:02 +00:00
G Johansson 73f27549e4 Error better name 2025-01-13 17:58:40 +00:00
G Johansson 1882b914dc render templates calls availability render 2025-01-13 17:54:19 +00:00
G Johansson 06f99dc9ba Mods 2025-01-07 23:07:03 +00:00
G Johansson 2e2c718d94 Remove render complex for availability 2025-01-07 21:18:12 +00:00
G Johansson b8f56a6ed6 Fix 2025-01-07 21:18:12 +00:00
G Johansson db37dbec03 Fix 2025-01-07 21:18:12 +00:00
G Johansson 579f44468e Only render templates if availability is true 2025-01-07 21:18:12 +00:00
G Johansson d452e957c9 Last fix 2025-01-07 21:18:12 +00:00
G Johansson 5f9bcd583b Mod 2025-01-07 21:18:12 +00:00
G Johansson c0c508c7a2 Fix 2025-01-07 21:18:11 +00:00
Erik 13f5adfa84 Try a different approach 2025-01-07 21:18:11 +00:00
G Johansson a07a3a61bf Add test 2025-01-07 21:18:11 +00:00
G Johansson 848162debd Fix when state breaks to stringify 2025-01-07 21:18:11 +00:00
G Johansson 07cd669bc1 Fix availability for manual trigger entities 2025-01-07 21:18:11 +00:00
24 changed files with 3789 additions and 37 deletions
+1 -1
View File
@@ -21,6 +21,7 @@ _LOGGER = logging.getLogger(__name__)
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.CLIMATE,
Platform.EVENT,
Platform.FAN,
Platform.LIGHT,
Platform.SELECT,
@@ -28,7 +29,6 @@ PLATFORMS = [
Platform.TIME,
]
KEEP_ALIVE_INTERVAL = timedelta(minutes=1)
SYNC_TIME_INTERVAL = timedelta(hours=1)
+91
View File
@@ -0,0 +1,91 @@
"""Support for Balboa events."""
from __future__ import annotations
from datetime import datetime, timedelta
from pybalboa import EVENT_UPDATE, SpaClient
from homeassistant.components.event import EventEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_track_time_interval
from . import BalboaConfigEntry
from .entity import BalboaEntity
FAULT = "fault"
FAULT_DATE = "fault_date"
REQUEST_FAULT_LOG_INTERVAL = timedelta(minutes=5)
FAULT_MESSAGE_CODE_MAP: dict[int, str] = {
15: "sensor_out_of_sync",
16: "low_flow",
17: "flow_failed",
18: "settings_reset",
19: "priming_mode",
20: "clock_failed",
21: "settings_reset",
22: "memory_failure",
26: "service_sensor_sync",
27: "heater_dry",
28: "heater_may_be_dry",
29: "water_too_hot",
30: "heater_too_hot",
31: "sensor_a_fault",
32: "sensor_b_fault",
34: "pump_stuck",
35: "hot_fault",
36: "gfci_test_failed",
37: "standby_mode",
}
FAULT_EVENT_TYPES = sorted(set(FAULT_MESSAGE_CODE_MAP.values()))
async def async_setup_entry(
hass: HomeAssistant,
entry: BalboaConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the spa's events."""
async_add_entities([BalboaEventEntity(entry.runtime_data)])
class BalboaEventEntity(BalboaEntity, EventEntity):
"""Representation of a Balboa event entity."""
_attr_event_types = FAULT_EVENT_TYPES
_attr_translation_key = FAULT
def __init__(self, spa: SpaClient) -> None:
"""Initialize a Balboa event entity."""
super().__init__(spa, FAULT)
@callback
def _async_handle_event(self) -> None:
"""Handle the fault event."""
if not (fault := self._client.fault):
return
fault_date = fault.fault_datetime.isoformat()
if self.state_attributes.get(FAULT_DATE) != fault_date:
self._trigger_event(
FAULT_MESSAGE_CODE_MAP.get(fault.message_code, fault.message),
{FAULT_DATE: fault_date, "code": fault.message_code},
)
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
await super().async_added_to_hass()
self.async_on_remove(self._client.on(EVENT_UPDATE, self._async_handle_event))
async def request_fault_log(now: datetime | None = None) -> None:
"""Request the most recent fault log."""
await self._client.request_fault_log()
await request_fault_log()
self.async_on_remove(
async_track_time_interval(
self.hass, request_fault_log, REQUEST_FAULT_LOG_INTERVAL
)
)
@@ -57,6 +57,35 @@
}
}
},
"event": {
"fault": {
"name": "Fault",
"state_attributes": {
"event_type": {
"state": {
"sensor_out_of_sync": "Sensors are out of sync",
"low_flow": "The water flow is low",
"flow_failed": "The water flow has failed",
"settings_reset": "The settings have been reset",
"priming_mode": "Priming mode",
"clock_failed": "The clock has failed",
"memory_failure": "Program memory failure",
"service_sensor_sync": "Sensors are out of sync -- call for service",
"heater_dry": "The heater is dry",
"heater_may_be_dry": "The heater may be dry",
"water_too_hot": "The water is too hot",
"heater_too_hot": "The heater is too hot",
"sensor_a_fault": "Sensor A fault",
"sensor_b_fault": "Sensor B fault",
"pump_stuck": "A pump may be stuck on",
"hot_fault": "Hot fault",
"gfci_test_failed": "The GFCI test failed",
"standby_mode": "Standby mode (hold mode)"
}
}
}
}
},
"fan": {
"pump": {
"name": "Pump {index}"
@@ -16,7 +16,7 @@
"loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"],
"mqtt": ["esphome/discover/#"],
"requirements": [
"aioesphomeapi==29.3.1",
"aioesphomeapi==29.3.2",
"esphome-dashboard-api==1.2.3",
"bleak-esphome==2.9.0"
],
+10 -10
View File
@@ -111,7 +111,7 @@
},
"services": {
"add_all_link": {
"name": "Add all link",
"name": "Add All-Link",
"description": "Tells the Insteon Modem (IM) start All-Linking mode. Once the IM is in All-Linking mode, press the link button on the device to complete All-Linking.",
"fields": {
"group": {
@@ -120,13 +120,13 @@
},
"mode": {
"name": "[%key:common::config_flow::data::mode%]",
"description": "Linking mode controller - IM is controller responder - IM is responder."
"description": "Linking mode of the Insteon Modem."
}
}
},
"delete_all_link": {
"name": "Delete all link",
"description": "Tells the Insteon Modem (IM) to remove an All-Link record from the All-Link Database of the IM and a device. Once the IM is set to delete the link, press the link button on the corresponding device to complete the process.",
"name": "Delete All-Link",
"description": "Tells the Insteon Modem (IM) to remove an All-Link record from the All-Link database of the IM and a device. Once the IM is set to delete the link, press the link button on the corresponding device to complete the process.",
"fields": {
"group": {
"name": "Group",
@@ -135,8 +135,8 @@
}
},
"load_all_link_database": {
"name": "Load all link database",
"description": "Load the All-Link Database for a device. WARNING - Loading a device All-LInk database is very time consuming and inconsistent. This may take a LONG time and may need to be repeated to obtain all records.",
"name": "Load All-Link database",
"description": "Loads the All-Link database for a device. WARNING - Loading a device All-Link database is very time consuming and inconsistent. This may take a LONG time and may need to be repeated to obtain all records.",
"fields": {
"entity_id": {
"name": "Entity",
@@ -149,8 +149,8 @@
}
},
"print_all_link_database": {
"name": "Print all link database",
"description": "Prints the All-Link Database for a device. Requires that the All-Link Database is loaded into memory.",
"name": "Print All-Link database",
"description": "Prints the All-Link database for a device. Requires that the All-Link database is loaded into memory.",
"fields": {
"entity_id": {
"name": "Entity",
@@ -159,8 +159,8 @@
}
},
"print_im_all_link_database": {
"name": "Print IM all link database",
"description": "Prints the All-Link Database for the INSTEON Modem (IM)."
"name": "Print IM All-Link database",
"description": "Prints the All-Link database for the INSTEON Modem (IM)."
},
"x10_all_units_off": {
"name": "X10 all units off",
+8
View File
@@ -63,6 +63,10 @@ COMMAND_TEST_FAILURE_STOP = "test.failure.stop"
COMMAND_TEST_PANEL_START = "test.panel.start"
COMMAND_TEST_PANEL_STOP = "test.panel.stop"
COMMAND_TEST_SYSTEM_START = "test.system.start"
COMMAND_OUTLET1_OFF = "outlet.1.load.off"
COMMAND_OUTLET1_ON = "outlet.1.load.on"
COMMAND_OUTLET2_OFF = "outlet.2.load.off"
COMMAND_OUTLET2_ON = "outlet.2.load.on"
INTEGRATION_SUPPORTED_COMMANDS = {
COMMAND_BEEPER_DISABLE,
@@ -91,4 +95,8 @@ INTEGRATION_SUPPORTED_COMMANDS = {
COMMAND_TEST_PANEL_START,
COMMAND_TEST_PANEL_STOP,
COMMAND_TEST_SYSTEM_START,
COMMAND_OUTLET1_OFF,
COMMAND_OUTLET1_ON,
COMMAND_OUTLET2_OFF,
COMMAND_OUTLET2_ON,
}
+5 -1
View File
@@ -74,7 +74,11 @@
"test_failure_stop": "Stop simulating a power failure",
"test_panel_start": "Start testing the UPS panel",
"test_panel_stop": "Stop a UPS panel test",
"test_system_start": "Start a system test"
"test_system_start": "Start a system test",
"outlet_1_load_on": "Power outlet 1 on",
"outlet_1_load_off": "Power outlet 1 off",
"outlet_2_load_on": "Power outlet 2 on",
"outlet_2_load_off": "Power outlet 2 off"
}
},
"entity": {
@@ -34,7 +34,7 @@ class SmartThingsConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
"""Create an entry for SmartThings."""
if data[CONF_TOKEN]["scope"].split() != SCOPES:
if not set(data[CONF_TOKEN]["scope"].split()) >= set(SCOPES):
return self.async_abort(reason="missing_scopes")
client = SmartThings(session=async_get_clientsession(self.hass))
client.authenticate(data[CONF_TOKEN][CONF_ACCESS_TOKEN])
+8 -2
View File
@@ -196,7 +196,10 @@ class ViCareFan(ViCareEntity, FanEntity):
@property
def is_on(self) -> bool | None:
"""Return true if the entity is on."""
if self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY):
if (
self._attr_supported_features & FanEntityFeature.TURN_OFF
and self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY)
):
return False
return self.percentage is not None and self.percentage > 0
@@ -209,7 +212,10 @@ class ViCareFan(ViCareEntity, FanEntity):
@property
def icon(self) -> str | None:
"""Return the icon to use in the frontend."""
if self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY):
if (
self._attr_supported_features & FanEntityFeature.TURN_OFF
and self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY)
):
return "mdi:fan-off"
if hasattr(self, "_attr_preset_mode"):
if self._attr_preset_mode == VentilationMode.VENTILATION:
@@ -121,6 +121,7 @@ class TriggerBaseEntity(Entity):
self._rendered = dict(self._static_rendered)
self._parse_result = {CONF_AVAILABILITY}
self._attr_device_class = config.get(CONF_DEVICE_CLASS)
self._render_error = False
@property
def name(self) -> str | None:
@@ -146,7 +147,7 @@ class TriggerBaseEntity(Entity):
def available(self) -> bool:
"""Return availability of the entity."""
return (
self._rendered is not self._static_rendered
self._render_error is False
and
# Check against False so `None` is ok
self._rendered.get(CONF_AVAILABILITY) is not False
@@ -176,12 +177,34 @@ class TriggerBaseEntity(Entity):
extra_state_attributes[attr] = last_state.attributes[attr]
self._rendered[CONF_ATTRIBUTES] = extra_state_attributes
def _render_availability_template(self, variables: dict[str, Any]) -> None:
"""Render availability template."""
self._render_error = False
rendered = {**self._static_rendered, **self._rendered}
key = CONF_AVAILABILITY
try:
if key in self._to_render_simple:
rendered[key] = self._config[key].async_render(
variables,
parse_result=key in self._parse_result,
)
except TemplateError as err:
logging.getLogger(f"{__package__}.{self.entity_id.split('.')[0]}").error(
"Error rendering %s template for %s: %s", key, self.entity_id, err
)
self._render_error = True
self._rendered = rendered
def _render_templates(self, variables: dict[str, Any]) -> None:
"""Render templates."""
self._render_availability_template(variables)
rendered = dict(self._rendered)
if CONF_AVAILABILITY in rendered and rendered[CONF_AVAILABILITY] is False:
return
try:
rendered = dict(self._static_rendered)
for key in self._to_render_simple:
if key == CONF_AVAILABILITY:
continue
rendered[key] = self._config[key].async_render(
variables,
parse_result=key in self._parse_result,
@@ -198,13 +221,13 @@ class TriggerBaseEntity(Entity):
self._config[CONF_ATTRIBUTES],
variables,
)
self._rendered = rendered
except TemplateError as err:
logging.getLogger(f"{__package__}.{self.entity_id.split('.')[0]}").error(
"Error rendering %s template for %s: %s", key, self.entity_id, err
)
self._rendered = self._static_rendered
self._render_error = True
return
self._rendered = rendered
class ManualTriggerEntity(TriggerBaseEntity):
@@ -230,16 +253,15 @@ class ManualTriggerEntity(TriggerBaseEntity):
Implementing class should call this last in update method to render templates.
Ex: self._process_manual_data(payload)
"""
run_variables: dict[str, Any] = {"value": value}
# Silently try if variable is a json and store result in `value_json` if it is.
with contextlib.suppress(*JSON_DECODE_EXCEPTIONS):
run_variables["value_json"] = json_loads(run_variables["value"])
variables = {
"this": TemplateStateFromEntityId(self.hass, self.entity_id),
**(run_variables or {}),
}
self._render_templates(variables)
+1 -1
View File
@@ -243,7 +243,7 @@ aioelectricitymaps==0.4.0
aioemonitor==1.0.5
# homeassistant.components.esphome
aioesphomeapi==29.3.1
aioesphomeapi==29.3.2
# homeassistant.components.flo
aioflo==2021.11.0
+1 -1
View File
@@ -231,7 +231,7 @@ aioelectricitymaps==0.4.0
aioemonitor==1.0.5
# homeassistant.components.esphome
aioesphomeapi==29.3.1
aioesphomeapi==29.3.2
# homeassistant.components.flo
aioflo==2021.11.0
+2
View File
@@ -68,4 +68,6 @@ def client_fixture() -> Generator[MagicMock]:
client.pumps = []
client.temperature_range.state = LowHighRange.LOW
client.fault = None
yield client
@@ -0,0 +1,90 @@
# serializer version: 1
# name: test_events[event.fakespa_fault-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'event_types': list([
'clock_failed',
'flow_failed',
'gfci_test_failed',
'heater_dry',
'heater_may_be_dry',
'heater_too_hot',
'hot_fault',
'low_flow',
'memory_failure',
'priming_mode',
'pump_stuck',
'sensor_a_fault',
'sensor_b_fault',
'sensor_out_of_sync',
'service_sensor_sync',
'settings_reset',
'standby_mode',
'water_too_hot',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'event',
'entity_category': None,
'entity_id': 'event.fakespa_fault',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Fault',
'platform': 'balboa',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'fault',
'unique_id': 'FakeSpa-fault-c0ffee',
'unit_of_measurement': None,
})
# ---
# name: test_events[event.fakespa_fault-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'event_type': None,
'event_types': list([
'clock_failed',
'flow_failed',
'gfci_test_failed',
'heater_dry',
'heater_may_be_dry',
'heater_too_hot',
'hot_fault',
'low_flow',
'memory_failure',
'priming_mode',
'pump_stuck',
'sensor_a_fault',
'sensor_b_fault',
'sensor_out_of_sync',
'service_sensor_sync',
'settings_reset',
'standby_mode',
'water_too_hot',
]),
'friendly_name': 'FakeSpa Fault',
}),
'context': <ANY>,
'entity_id': 'event.fakespa_fault',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
+82
View File
@@ -0,0 +1,82 @@
"""Tests of the events of the balboa integration."""
from __future__ import annotations
from datetime import datetime
from unittest.mock import MagicMock, patch
import pytest
from syrupy import SnapshotAssertion
from homeassistant.components.event import ATTR_EVENT_TYPE
from homeassistant.const import STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import init_integration
from tests.common import snapshot_platform
ENTITY_EVENT = "event.fakespa_fault"
FAULT_DATE = "fault_date"
async def test_events(
hass: HomeAssistant,
client: MagicMock,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test spa events."""
with patch("homeassistant.components.balboa.PLATFORMS", [Platform.EVENT]):
entry = await init_integration(hass)
await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id)
async def test_event(hass: HomeAssistant, client: MagicMock) -> None:
"""Test spa fault event."""
await init_integration(hass)
# check the state is unknown
state = hass.states.get(ENTITY_EVENT)
assert state.state == STATE_UNKNOWN
# set a fault
client.fault = MagicMock(
fault_datetime=datetime(2025, 2, 15, 13, 0), message_code=16
)
client.emit("")
await hass.async_block_till_done()
# check new state is what we expect
state = hass.states.get(ENTITY_EVENT)
assert state.attributes[ATTR_EVENT_TYPE] == "low_flow"
assert state.attributes[FAULT_DATE] == "2025-02-15T13:00:00"
assert state.attributes["code"] == 16
# set fault to None
client.fault = None
client.emit("")
await hass.async_block_till_done()
# validate state remains unchanged
state = hass.states.get(ENTITY_EVENT)
assert state.attributes[ATTR_EVENT_TYPE] == "low_flow"
assert state.attributes[FAULT_DATE] == "2025-02-15T13:00:00"
assert state.attributes["code"] == 16
# set fault to an unknown one
client.fault = MagicMock(
fault_datetime=datetime(2025, 2, 15, 14, 0), message_code=-1
)
# validate a ValueError is raises
with pytest.raises(ValueError):
client.emit("")
await hass.async_block_till_done()
# validate state remains unchanged
state = hass.states.get(ENTITY_EVENT)
assert state.attributes[ATTR_EVENT_TYPE] == "low_flow"
assert state.attributes[FAULT_DATE] == "2025-02-15T13:00:00"
assert state.attributes["code"] == 16
@@ -808,3 +808,52 @@ async def test_availability(
entity_state = hass.states.get("sensor.test")
assert entity_state
assert entity_state.state == STATE_UNAVAILABLE
@pytest.mark.parametrize(
"get_config",
[
{
"command_line": [
{
"sensor": {
"name": "Test",
"command": "echo {{ states.sensor.input_sensor.state }}",
"availability": "{{ value|is_number}}",
"unit_of_measurement": " ",
"state_class": "measurement",
}
}
]
}
],
)
async def test_template_render_not_break_for_availability(
hass: HomeAssistant, load_yaml_integration: None
) -> None:
"""Ensure command with templates get rendered properly."""
hass.states.async_set("sensor.input_sensor", "sensor_value")
# Give time for template to load
async_fire_time_changed(
hass,
dt_util.utcnow() + timedelta(minutes=1),
)
await hass.async_block_till_done(wait_background_tasks=True)
entity_state = hass.states.get("sensor.test")
assert entity_state
assert entity_state.state == STATE_UNAVAILABLE
hass.states.async_set("sensor.input_sensor", "1")
# Give time for template to load
async_fire_time_changed(
hass,
dt_util.utcnow() + timedelta(minutes=1),
)
await hass.async_block_till_done(wait_background_tasks=True)
entity_state = hass.states.get("sensor.test")
assert entity_state
assert entity_state.state == "1"
+51
View File
@@ -1054,3 +1054,54 @@ async def test_availability_in_config(hass: HomeAssistant) -> None:
state = hass.states.get("sensor.rest_sensor")
assert state.state == STATE_UNAVAILABLE
@respx.mock
async def test_json_response_with_availability(hass: HomeAssistant) -> None:
"""Test availability with complex json."""
respx.get("http://localhost").respond(
status_code=HTTPStatus.OK,
json={"heartbeatList": {"1": [{"status": 1, "ping": 21.4}]}},
)
assert await async_setup_component(
hass,
DOMAIN,
{
DOMAIN: [
{
"resource": "http://localhost",
"sensor": [
{
"unique_id": "complex_json",
"name": "complex_json",
"value_template": '{% set v = value_json.heartbeatList["1"][-1] %}{{ v.ping }}',
"availability": '{% set v = value_json.heartbeatList["1"][-1] %}{{ v.status == 1 and is_number(v.ping) }}',
"unit_of_measurement": "ms",
"state_class": "measurement",
}
],
}
]
},
)
await async_setup_component(hass, "homeassistant", {})
await hass.async_block_till_done()
assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1
state = hass.states.get("sensor.complex_json")
assert state.state == "21.4"
respx.get("http://localhost").respond(
status_code=HTTPStatus.OK,
json={"heartbeatList": {"1": [{"status": 0, "ping": None}]}},
)
await hass.services.async_call(
"homeassistant",
"update_entity",
{ATTR_ENTITY_ID: ["sensor.complex_json"]},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get("sensor.complex_json")
assert state.state == STATE_UNAVAILABLE
@@ -261,7 +261,7 @@ async def test_reauthentication(
"expires_in": 82806,
"scope": "r:devices:* w:devices:* x:devices:* r:hubs:* "
"r:locations:* w:locations:* x:locations:* "
"r:scenes:* x:scenes:* r:rules:* w:rules:* sse",
"r:scenes:* x:scenes:* r:rules:* sse w:rules:*",
"access_tier": 0,
"installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324",
},
@@ -279,7 +279,7 @@ async def test_reauthentication(
"expires_in": 82806,
"scope": "r:devices:* w:devices:* x:devices:* r:hubs:* "
"r:locations:* w:locations:* x:locations:* "
"r:scenes:* x:scenes:* r:rules:* w:rules:* sse",
"r:scenes:* x:scenes:* r:rules:* sse w:rules:*",
"access_tier": 0,
"installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324",
}
@@ -2,7 +2,9 @@
import pytest
from homeassistant.components import sensor
from homeassistant.components.template import template_entity
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import template
@@ -22,3 +24,46 @@ async def test_template_entity_requires_hass_set(hass: HomeAssistant) -> None:
entity.add_template_attribute("_hello", tpl_with_hass)
assert len(entity._template_attrs.get(tpl_with_hass, [])) == 1
@pytest.mark.parametrize(("count", "domain"), [(1, sensor.DOMAIN)])
@pytest.mark.parametrize(
"config",
[
{
"sensor": {
"platform": "template",
"sensors": {
"test_template_sensor": {
"value_template": "{{ states.sensor.test_sensor.state }}",
"availability_template": "{{ is_state('sensor.test_sensor', 'on') }}",
"icon_template": "{% if states.sensor.test_sensor.state == 'on' %}mdi:on{% else %}mdi:off{% endif %}",
}
},
},
},
],
)
@pytest.mark.usefixtures("start_ha")
async def test_unavailable_does_not_render_other_state_attributes(
hass: HomeAssistant,
) -> None:
"""Test when entity goes unavailable, other state attributes are not rendered."""
hass.states.async_set("sensor.test_sensor", STATE_OFF)
# When template returns true..
hass.states.async_set("sensor.test_sensor", STATE_ON)
await hass.async_block_till_done()
# Device State should not be unavailable
assert hass.states.get("sensor.test_template_sensor").state != STATE_UNAVAILABLE
assert hass.states.get("sensor.test_template_sensor").attributes["icon"] == "mdi:on"
# When Availability template returns false
hass.states.async_set("sensor.test_sensor", STATE_OFF)
await hass.async_block_till_done()
# device state should be unavailable
assert hass.states.get("sensor.test_template_sensor").state == STATE_UNAVAILABLE
# Icon should be mdi:on as going unavailable does not render state attributes
assert hass.states.get("sensor.test_template_sensor").attributes["icon"] == "mdi:on"
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,69 @@
# serializer version: 1
# name: test_all_entities[fan.model0_ventilation-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'preset_modes': list([
<VentilationMode.PERMANENT: 'permanent'>,
<VentilationMode.VENTILATION: 'ventilation'>,
<VentilationMode.SENSOR_DRIVEN: 'sensor_driven'>,
<VentilationMode.SENSOR_OVERRIDE: 'sensor_override'>,
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'fan',
'entity_category': None,
'entity_id': 'fan.model0_ventilation',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': 'mdi:fan',
'original_name': 'Ventilation',
'platform': 'vicare',
'previous_unique_id': None,
'supported_features': <FanEntityFeature: 9>,
'translation_key': 'ventilation',
'unique_id': 'gateway0_deviceSerialViAir300F-ventilation',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[fan.model0_ventilation-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'model0 Ventilation',
'icon': 'mdi:fan',
'percentage': 0,
'percentage_step': 25.0,
'preset_mode': None,
'preset_modes': list([
<VentilationMode.PERMANENT: 'permanent'>,
<VentilationMode.VENTILATION: 'ventilation'>,
<VentilationMode.SENSOR_DRIVEN: 'sensor_driven'>,
<VentilationMode.SENSOR_OVERRIDE: 'sensor_override'>,
]),
'supported_features': <FanEntityFeature: 9>,
}),
'context': <ANY>,
'entity_id': 'fan.model0_ventilation',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[fan.model1_ventilation-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@@ -62,3 +127,64 @@
'state': 'off',
})
# ---
# name: test_all_entities[fan.model2_ventilation-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'preset_modes': list([
<VentilationMode.VENTILATION: 'ventilation'>,
<VentilationMode.STANDBY: 'standby'>,
<VentilationMode.STANDARD: 'standard'>,
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'fan',
'entity_category': None,
'entity_id': 'fan.model2_ventilation',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': 'mdi:fan',
'original_name': 'Ventilation',
'platform': 'vicare',
'previous_unique_id': None,
'supported_features': <FanEntityFeature: 8>,
'translation_key': 'ventilation',
'unique_id': 'gateway2_################-ventilation',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[fan.model2_ventilation-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'model2 Ventilation',
'icon': 'mdi:fan',
'preset_mode': None,
'preset_modes': list([
<VentilationMode.VENTILATION: 'ventilation'>,
<VentilationMode.STANDBY: 'standby'>,
<VentilationMode.STANDARD: 'standard'>,
]),
'supported_features': <FanEntityFeature: 8>,
}),
'context': <ANY>,
'entity_id': 'fan.model2_ventilation',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
+3 -1
View File
@@ -23,7 +23,9 @@ async def test_all_entities(
entity_registry: er.EntityRegistry,
) -> None:
"""Test all entities."""
fixtures: list[Fixture] = [Fixture({"type:boiler"}, "vicare/Vitodens300W.json")]
fixtures: list[Fixture] = [
Fixture({"type:boiler"}, "vicare/Vitodens300W.json"),
]
with (
patch(f"{MODULE}.login", return_value=MockPyViCare(fixtures)),
patch(f"{MODULE}.PLATFORMS", [Platform.CLIMATE]),
+1
View File
@@ -26,6 +26,7 @@ async def test_all_entities(
fixtures: list[Fixture] = [
Fixture({"type:ventilation"}, "vicare/ViAir300F.json"),
Fixture({"type:ventilation"}, "vicare/VitoPure.json"),
Fixture({"type:heatpump"}, "vicare/Vitocal222G_Vitovent300W.json"),
]
with (
patch(f"{MODULE}.login", return_value=MockPyViCare(fixtures)),
+133 -8
View File
@@ -1,18 +1,27 @@
"""Test template trigger entity."""
from typing import Any
import pytest
from homeassistant.const import CONF_ICON, CONF_NAME, STATE_OFF, STATE_ON, STATE_UNKNOWN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import template
from homeassistant.helpers.trigger_template_entity import ManualTriggerEntity
from homeassistant.helpers.trigger_template_entity import (
CONF_AVAILABILITY,
CONF_PICTURE,
ManualTriggerEntity,
)
async def test_template_entity_requires_hass_set(hass: HomeAssistant) -> None:
"""Test manual trigger template entity."""
config = {
"name": template.Template("test_entity", hass),
"icon": template.Template(
CONF_NAME: template.Template("test_entity", hass),
CONF_ICON: template.Template(
'{% if value=="on" %} mdi:on {% else %} mdi:off {% endif %}', hass
),
"picture": template.Template(
CONF_PICTURE: template.Template(
'{% if value=="on" %} /local/picture_on {% else %} /local/picture_off {% endif %}',
hass,
),
@@ -20,21 +29,137 @@ async def test_template_entity_requires_hass_set(hass: HomeAssistant) -> None:
entity = ManualTriggerEntity(hass, config)
entity.entity_id = "test.entity"
hass.states.async_set("test.entity", "on")
hass.states.async_set("test.entity", STATE_ON)
await entity.async_added_to_hass()
entity._process_manual_data("on")
entity._process_manual_data(STATE_ON)
await hass.async_block_till_done()
assert entity.name == "test_entity"
assert entity.icon == "mdi:on"
assert entity.entity_picture == "/local/picture_on"
hass.states.async_set("test.entity", "off")
hass.states.async_set("test.entity", STATE_OFF)
await entity.async_added_to_hass()
entity._process_manual_data("off")
entity._process_manual_data(STATE_OFF)
await hass.async_block_till_done()
assert entity.name == "test_entity"
assert entity.icon == "mdi:off"
assert entity.entity_picture == "/local/picture_off"
async def test_trigger_template_availability(hass: HomeAssistant) -> None:
"""Test manual trigger template entity availability template."""
config = {
CONF_NAME: template.Template("test_entity", hass),
CONF_ICON: template.Template(
'{% if value=="on" %} mdi:on {% else %} mdi:off {% endif %}', hass
),
CONF_PICTURE: template.Template(
'{% if value=="on" %} /local/picture_on {% else %} /local/picture_off {% endif %}',
hass,
),
CONF_AVAILABILITY: template.Template('{{ has_value("test.entity") }}', hass),
}
entity = ManualTriggerEntity(hass, config)
entity.entity_id = "test.entity"
hass.states.async_set("test.entity", STATE_ON)
await entity.async_added_to_hass()
entity._process_manual_data(STATE_ON)
await hass.async_block_till_done()
assert entity.name == "test_entity"
assert entity.icon == "mdi:on"
assert entity.entity_picture == "/local/picture_on"
assert entity.available is True
hass.states.async_set("test.entity", STATE_OFF)
await entity.async_added_to_hass()
entity._process_manual_data(STATE_OFF)
await hass.async_block_till_done()
assert entity.name == "test_entity"
assert entity.icon == "mdi:off"
assert entity.entity_picture == "/local/picture_off"
assert entity.available is True
hass.states.async_set("test.entity", STATE_UNKNOWN)
await entity.async_added_to_hass()
entity._process_manual_data(STATE_UNKNOWN)
await hass.async_block_till_done()
assert entity.name == "test_entity"
assert entity.icon == "mdi:off"
assert entity.entity_picture == "/local/picture_off"
assert entity.available is False
async def test_trigger_template_availability_fails(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:
"""Test manual trigger template entity when availability render fails."""
config = {
CONF_NAME: template.Template("test_entity", hass),
CONF_ICON: template.Template(
'{% if value=="on" %} mdi:on {% else %} mdi:off {% endif %}', hass
),
CONF_PICTURE: template.Template(
'{% if value=="on" %} /local/picture_on {% else %} /local/picture_off {% endif %}',
hass,
),
CONF_AVAILABILITY: template.Template("{{ incorrect ", hass),
}
entity = ManualTriggerEntity(hass, config)
entity.entity_id = "test.entity"
hass.states.async_set("test.entity", STATE_ON)
await entity.async_added_to_hass()
entity._process_manual_data(STATE_ON)
await hass.async_block_till_done()
assert "Error rendering availability template for test.entity" in caplog.text
async def test_trigger_template_complex(hass: HomeAssistant) -> None:
"""Test manual trigger template entity complex template."""
complex_template = """
{% set d = {'test_key':'test_data'} %}
{{ dict(d) }}
"""
config = {
CONF_NAME: template.Template("test_entity", hass),
CONF_ICON: template.Template(
'{% if value=="on" %} mdi:on {% else %} mdi:off {% endif %}', hass
),
CONF_PICTURE: template.Template(
'{% if value=="on" %} /local/picture_on {% else %} /local/picture_off {% endif %}',
hass,
),
CONF_AVAILABILITY: template.Template('{{ has_value("test.entity") }}', hass),
"other_key": template.Template(complex_template, hass),
}
class TestEntity(ManualTriggerEntity):
"""Test entity class."""
extra_template_keys_complex = ("other_key",)
@property
def some_other_key(self) -> dict[str, Any] | None:
"""Return extra attributes."""
return self._rendered.get("other_key")
entity = TestEntity(hass, config)
entity.entity_id = "test.entity"
hass.states.async_set("test.entity", STATE_ON)
await entity.async_added_to_hass()
entity._process_manual_data(STATE_ON)
await hass.async_block_till_done()
assert entity.some_other_key == {"test_key": "test_data"}