diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py index b36e826e711e65..13556e8293c1b2 100644 --- a/homeassistant/components/matter/binary_sensor.py +++ b/homeassistant/components/matter/binary_sensor.py @@ -88,6 +88,17 @@ def _update_from_device(self) -> None: entity_class=MatterBinarySensor, required_attributes=(clusters.OccupancySensing.Attributes.Occupancy,), ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="ThermostatOccupancySensor", + device_class=BinarySensorDeviceClass.OCCUPANCY, + # The first bit = if occupied + device_to_ha=lambda x: (x & 1 == 1) if x is not None else None, + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.Thermostat.Attributes.Occupancy,), + ), MatterDiscoverySchema( platform=Platform.BINARY_SENSOR, entity_description=MatterBinarySensorEntityDescription( diff --git a/homeassistant/components/sensibo/__init__.py b/homeassistant/components/sensibo/__init__.py index 06b5ea6588a7ae..35750cd28bf091 100644 --- a/homeassistant/components/sensibo/__init__.py +++ b/homeassistant/components/sensibo/__init__.py @@ -8,15 +8,26 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, LOGGER, PLATFORMS from .coordinator import SensiboDataUpdateCoordinator +from .services import async_setup_services from .util import NoDevicesError, NoUsernameError, async_validate_api type SensiboConfigEntry = ConfigEntry[SensiboDataUpdateCoordinator] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Sensibo component.""" + async_setup_services(hass) + + return True + async def async_setup_entry(hass: HomeAssistant, entry: SensiboConfigEntry) -> bool: """Set up Sensibo from a config entry.""" diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index a40cb110f667a7..daffad0447a278 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -5,26 +5,14 @@ from bisect import bisect_left from typing import TYPE_CHECKING, Any -import voluptuous as vol - from homeassistant.components.climate import ( - ATTR_FAN_MODE, - ATTR_HVAC_MODE, - ATTR_SWING_MODE, ClimateEntity, ClimateEntityFeature, HVACMode, ) -from homeassistant.const import ( - ATTR_MODE, - ATTR_STATE, - ATTR_TEMPERATURE, - PRECISION_TENTHS, - UnitOfTemperature, -) -from homeassistant.core import HomeAssistant, SupportsResponse +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.unit_conversion import TemperatureConverter @@ -33,30 +21,6 @@ from .coordinator import SensiboDataUpdateCoordinator from .entity import SensiboDeviceBaseEntity, async_handle_api_call -SERVICE_ASSUME_STATE = "assume_state" -SERVICE_ENABLE_TIMER = "enable_timer" -ATTR_MINUTES = "minutes" -SERVICE_ENABLE_PURE_BOOST = "enable_pure_boost" -SERVICE_DISABLE_PURE_BOOST = "disable_pure_boost" -SERVICE_FULL_STATE = "full_state" -SERVICE_ENABLE_CLIMATE_REACT = "enable_climate_react" -SERVICE_GET_DEVICE_CAPABILITIES = "get_device_capabilities" -ATTR_HIGH_TEMPERATURE_THRESHOLD = "high_temperature_threshold" -ATTR_HIGH_TEMPERATURE_STATE = "high_temperature_state" -ATTR_LOW_TEMPERATURE_THRESHOLD = "low_temperature_threshold" -ATTR_LOW_TEMPERATURE_STATE = "low_temperature_state" -ATTR_SMART_TYPE = "smart_type" - -ATTR_AC_INTEGRATION = "ac_integration" -ATTR_GEO_INTEGRATION = "geo_integration" -ATTR_INDOOR_INTEGRATION = "indoor_integration" -ATTR_OUTDOOR_INTEGRATION = "outdoor_integration" -ATTR_SENSITIVITY = "sensitivity" -ATTR_TARGET_TEMPERATURE = "target_temperature" -ATTR_HORIZONTAL_SWING_MODE = "horizontal_swing_mode" -ATTR_LIGHT = "light" -BOOST_INCLUSIVE = "boost_inclusive" - AVAILABLE_FAN_MODES = { "quiet", "low", @@ -162,66 +126,6 @@ def _add_remove_devices() -> None: entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices)) _add_remove_devices() - platform = entity_platform.async_get_current_platform() - platform.async_register_entity_service( - SERVICE_ASSUME_STATE, - { - vol.Required(ATTR_STATE): vol.In(["on", "off"]), - }, - "async_assume_state", - ) - platform.async_register_entity_service( - SERVICE_ENABLE_TIMER, - { - vol.Required(ATTR_MINUTES): cv.positive_int, - }, - "async_enable_timer", - ) - platform.async_register_entity_service( - SERVICE_ENABLE_PURE_BOOST, - { - vol.Required(ATTR_AC_INTEGRATION): bool, - vol.Required(ATTR_GEO_INTEGRATION): bool, - vol.Required(ATTR_INDOOR_INTEGRATION): bool, - vol.Required(ATTR_OUTDOOR_INTEGRATION): bool, - vol.Required(ATTR_SENSITIVITY): vol.In(["normal", "sensitive"]), - }, - "async_enable_pure_boost", - ) - platform.async_register_entity_service( - SERVICE_FULL_STATE, - { - vol.Required(ATTR_MODE): vol.In( - ["cool", "heat", "fan", "auto", "dry", "off"] - ), - vol.Optional(ATTR_TARGET_TEMPERATURE): int, - vol.Optional(ATTR_FAN_MODE): str, - vol.Optional(ATTR_SWING_MODE): str, - vol.Optional(ATTR_HORIZONTAL_SWING_MODE): str, - vol.Optional(ATTR_LIGHT): vol.In(["on", "off", "dim"]), - }, - "async_full_ac_state", - ) - platform.async_register_entity_service( - SERVICE_ENABLE_CLIMATE_REACT, - { - vol.Required(ATTR_HIGH_TEMPERATURE_THRESHOLD): vol.Coerce(float), - vol.Required(ATTR_HIGH_TEMPERATURE_STATE): dict, - vol.Required(ATTR_LOW_TEMPERATURE_THRESHOLD): vol.Coerce(float), - vol.Required(ATTR_LOW_TEMPERATURE_STATE): dict, - vol.Required(ATTR_SMART_TYPE): vol.In( - ["temperature", "feelslike", "humidity"] - ), - }, - "async_enable_climate_react", - ) - platform.async_register_entity_service( - SERVICE_GET_DEVICE_CAPABILITIES, - {vol.Required(ATTR_HVAC_MODE): vol.Coerce(HVACMode)}, - "async_get_device_capabilities", - supports_response=SupportsResponse.ONLY, - ) - class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): """Representation of a Sensibo climate device.""" diff --git a/homeassistant/components/sensibo/services.py b/homeassistant/components/sensibo/services.py new file mode 100644 index 00000000000000..682954e6d7c1ed --- /dev/null +++ b/homeassistant/components/sensibo/services.py @@ -0,0 +1,124 @@ +"""Sensibo services.""" + +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.components.climate import ( + ATTR_FAN_MODE, + ATTR_HVAC_MODE, + ATTR_SWING_MODE, + DOMAIN as CLIMATE_DOMAIN, + HVACMode, +) +from homeassistant.const import ATTR_MODE, ATTR_STATE +from homeassistant.core import HomeAssistant, SupportsResponse, callback +from homeassistant.helpers import config_validation as cv, service + +from .const import DOMAIN + +SERVICE_ASSUME_STATE = "assume_state" +SERVICE_ENABLE_TIMER = "enable_timer" +ATTR_MINUTES = "minutes" +SERVICE_ENABLE_PURE_BOOST = "enable_pure_boost" +SERVICE_DISABLE_PURE_BOOST = "disable_pure_boost" +SERVICE_FULL_STATE = "full_state" +SERVICE_ENABLE_CLIMATE_REACT = "enable_climate_react" +SERVICE_GET_DEVICE_CAPABILITIES = "get_device_capabilities" +ATTR_HIGH_TEMPERATURE_THRESHOLD = "high_temperature_threshold" +ATTR_HIGH_TEMPERATURE_STATE = "high_temperature_state" +ATTR_LOW_TEMPERATURE_THRESHOLD = "low_temperature_threshold" +ATTR_LOW_TEMPERATURE_STATE = "low_temperature_state" +ATTR_SMART_TYPE = "smart_type" + +ATTR_AC_INTEGRATION = "ac_integration" +ATTR_GEO_INTEGRATION = "geo_integration" +ATTR_INDOOR_INTEGRATION = "indoor_integration" +ATTR_OUTDOOR_INTEGRATION = "outdoor_integration" +ATTR_SENSITIVITY = "sensitivity" +ATTR_TARGET_TEMPERATURE = "target_temperature" +ATTR_HORIZONTAL_SWING_MODE = "horizontal_swing_mode" +ATTR_LIGHT = "light" +BOOST_INCLUSIVE = "boost_inclusive" + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register Sensibo services.""" + + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_ASSUME_STATE, + entity_domain=CLIMATE_DOMAIN, + schema={ + vol.Required(ATTR_STATE): vol.In(["on", "off"]), + }, + func="async_assume_state", + ) + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_ENABLE_TIMER, + entity_domain=CLIMATE_DOMAIN, + schema={ + vol.Required(ATTR_MINUTES): cv.positive_int, + }, + func="async_enable_timer", + ) + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_ENABLE_PURE_BOOST, + entity_domain=CLIMATE_DOMAIN, + schema={ + vol.Required(ATTR_AC_INTEGRATION): bool, + vol.Required(ATTR_GEO_INTEGRATION): bool, + vol.Required(ATTR_INDOOR_INTEGRATION): bool, + vol.Required(ATTR_OUTDOOR_INTEGRATION): bool, + vol.Required(ATTR_SENSITIVITY): vol.In(["normal", "sensitive"]), + }, + func="async_enable_pure_boost", + ) + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_FULL_STATE, + entity_domain=CLIMATE_DOMAIN, + schema={ + vol.Required(ATTR_MODE): vol.In( + ["cool", "heat", "fan", "auto", "dry", "off"] + ), + vol.Optional(ATTR_TARGET_TEMPERATURE): int, + vol.Optional(ATTR_FAN_MODE): str, + vol.Optional(ATTR_SWING_MODE): str, + vol.Optional(ATTR_HORIZONTAL_SWING_MODE): str, + vol.Optional(ATTR_LIGHT): vol.In(["on", "off", "dim"]), + }, + func="async_full_ac_state", + ) + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_ENABLE_CLIMATE_REACT, + entity_domain=CLIMATE_DOMAIN, + schema={ + vol.Required(ATTR_HIGH_TEMPERATURE_THRESHOLD): vol.Coerce(float), + vol.Required(ATTR_HIGH_TEMPERATURE_STATE): dict, + vol.Required(ATTR_LOW_TEMPERATURE_THRESHOLD): vol.Coerce(float), + vol.Required(ATTR_LOW_TEMPERATURE_STATE): dict, + vol.Required(ATTR_SMART_TYPE): vol.In( + ["temperature", "feelslike", "humidity"] + ), + }, + func="async_enable_climate_react", + ) + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_GET_DEVICE_CAPABILITIES, + entity_domain=CLIMATE_DOMAIN, + schema={vol.Required(ATTR_HVAC_MODE): vol.Coerce(HVACMode)}, + func="async_get_device_capabilities", + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index aca9644c5efad2..508365b5c0dce0 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -30,6 +30,7 @@ AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, @@ -69,6 +70,15 @@ async def async_setup_platform( ) -> None: """Set up the SQL sensor from yaml.""" if (conf := discovery_info) is None: + async_create_issue( + hass, + DOMAIN, + "sensor_platform_yaml_not_supported", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="platform_yaml_not_supported", + learn_more_url="https://www.home-assistant.io/integrations/sql/", + ) return name: Template = conf[CONF_NAME] diff --git a/homeassistant/components/sql/strings.json b/homeassistant/components/sql/strings.json index 7b4ad154981522..ae49dac049b900 100644 --- a/homeassistant/components/sql/strings.json +++ b/homeassistant/components/sql/strings.json @@ -166,6 +166,10 @@ "entity_id_query_does_full_table_scan": { "title": "SQL query does full table scan", "description": "The query `{query}` contains the keyword `entity_id` but does not reference the `states_meta` table. This will cause a full table scan and database instability. Please check the documentation and use `states_meta.entity_id` instead." + }, + "platform_yaml_not_supported": { + "title": "Platform YAML is not supported in SQL", + "description": "Platform YAML setup is not supported.\nChange from configuring it in the `sensor:` key to using the `sql:` key directly in configuration.yaml.\nTo see the detailed documentation, select Learn more." } } } diff --git a/tests/components/matter/fixtures/nodes/thermostat.json b/tests/components/matter/fixtures/nodes/thermostat.json index bb42b8926b9d33..620738d2e7e636 100644 --- a/tests/components/matter/fixtures/nodes/thermostat.json +++ b/tests/components/matter/fixtures/nodes/thermostat.json @@ -318,6 +318,7 @@ "1/64/65531": [0, 65528, 65529, 65531, 65532, 65533], "1/513/0": 2830, "1/513/1": 1250, + "1/513/2": 1, "1/513/3": null, "1/513/4": null, "1/513/5": null, diff --git a/tests/components/matter/snapshots/test_binary_sensor.ambr b/tests/components/matter/snapshots/test_binary_sensor.ambr index b31f241ec45d8d..5d1c9b029f952d 100644 --- a/tests/components/matter/snapshots/test_binary_sensor.ambr +++ b/tests/components/matter/snapshots/test_binary_sensor.ambr @@ -1222,6 +1222,55 @@ 'state': 'off', }) # --- +# name: test_binary_sensors[thermostat][binary_sensor.longan_link_hvac_occupancy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.longan_link_hvac_occupancy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Occupancy', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-ThermostatOccupancySensor-513-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[thermostat][binary_sensor.longan_link_hvac_occupancy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'occupancy', + 'friendly_name': 'Longan link HVAC Occupancy', + }), + 'context': , + 'entity_id': 'binary_sensor.longan_link_hvac_occupancy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_binary_sensors[valve][binary_sensor.valve_general_fault-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/test_binary_sensor.py b/tests/components/matter/test_binary_sensor.py index 06055af8c9dc38..3dcd129514e343 100644 --- a/tests/components/matter/test_binary_sensor.py +++ b/tests/components/matter/test_binary_sensor.py @@ -3,6 +3,7 @@ from collections.abc import Generator from unittest.mock import MagicMock, patch +from chip.clusters import Objects as clusters from matter_server.client.models.node import MatterNode from matter_server.common.models import EventType import pytest @@ -344,3 +345,31 @@ async def test_water_valve( state = hass.states.get("binary_sensor.valve_valve_leaking") assert state assert state.state == "on" + + +@pytest.mark.parametrize("node_fixture", ["thermostat"]) +async def test_thermostat_occupancy( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test thermostat occupancy.""" + state = hass.states.get("binary_sensor.longan_link_hvac_occupancy") + assert state + assert state.state == "on" + + # Test Occupancy attribute change + occupancy_attribute = clusters.Thermostat.Attributes.Occupancy + + set_node_attribute( + matter_node, + 1, + occupancy_attribute.cluster_id, + occupancy_attribute.attribute_id, + 0, + ) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("binary_sensor.longan_link_hvac_occupancy") + assert state + assert state.state == "off" diff --git a/tests/components/sensibo/test_climate.py b/tests/components/sensibo/test_climate.py index 7e848f3870c3b3..e2216f0c8ef76d 100644 --- a/tests/components/sensibo/test_climate.py +++ b/tests/components/sensibo/test_climate.py @@ -25,7 +25,9 @@ SERVICE_SET_TEMPERATURE, HVACMode, ) -from homeassistant.components.sensibo.climate import ( +from homeassistant.components.sensibo.climate import _find_valid_target_temp +from homeassistant.components.sensibo.const import DOMAIN +from homeassistant.components.sensibo.services import ( ATTR_AC_INTEGRATION, ATTR_GEO_INTEGRATION, ATTR_HIGH_TEMPERATURE_STATE, @@ -46,9 +48,7 @@ SERVICE_ENABLE_TIMER, SERVICE_FULL_STATE, SERVICE_GET_DEVICE_CAPABILITIES, - _find_valid_target_temp, ) -from homeassistant.components.sensibo.const import DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, diff --git a/tests/components/sql/test_sensor.py b/tests/components/sql/test_sensor.py index 388c4966e7b979..73879065999f63 100644 --- a/tests/components/sql/test_sensor.py +++ b/tests/components/sql/test_sensor.py @@ -345,7 +345,7 @@ async def test_templates_with_yaml( async def test_config_from_old_yaml( - recorder_mock: Recorder, hass: HomeAssistant + recorder_mock: Recorder, hass: HomeAssistant, issue_registry: ir.IssueRegistry ) -> None: """Test the SQL sensor from old yaml config does not create any entity.""" config = { @@ -366,6 +366,9 @@ async def test_config_from_old_yaml( state = hass.states.get("sensor.count_tables") assert not state + issue = issue_registry.async_get_issue(DOMAIN, "sensor_platform_yaml_not_supported") + assert issue is not None + assert issue.severity == ir.IssueSeverity.WARNING @pytest.mark.parametrize(