diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 6ee9ad951d962a..d5448e65114715 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -342,13 +342,13 @@ jobs: - name: Login to DockerHub if: matrix.registry == 'docker.io/homeassistant' - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -378,7 +378,7 @@ jobs: # 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev - name: Generate Docker metadata id: meta - uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: ${{ matrix.registry }}/home-assistant sep-tags: "," @@ -392,7 +392,7 @@ jobs: type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v3.7.1 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v3.7.1 - name: Copy architecture images to DockerHub if: matrix.registry == 'docker.io/homeassistant' @@ -521,7 +521,7 @@ jobs: persist-credentials: false - name: Login to GitHub Container Registry - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 with: registry: ghcr.io username: ${{ github.repository_owner }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 32909f1dbb3119..e026da1a34b728 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -116,7 +116,7 @@ jobs: # of a new uv cache entry after a version bump. echo "key=venv-${CACHE_VERSION}-${HA_SHORT_VERSION}-${HASH_REQUIREMENTS_TEST}-${HASH_REQUIREMENTS}-${HASH_REQUIREMENTS_ALL}-${HASH_PACKAGE_CONSTRAINTS}-${HASH_GEN_REQUIREMENTS}" >> $GITHUB_OUTPUT - name: Filter for core changes - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 id: core with: filters: .core_files.yaml @@ -131,7 +131,7 @@ jobs: echo "Result:" cat .integration_paths.yaml - name: Filter for integration changes - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 id: integrations with: filters: .integration_paths.yaml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 31ef4c06f05b81..080b4e0a1d2f26 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -28,11 +28,11 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 with: category: "/language:python" diff --git a/CODEOWNERS b/CODEOWNERS index bf877ec95f1b06..6a78706c143c76 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1814,8 +1814,8 @@ CLAUDE.md @home-assistant/core /tests/components/template/ @Petro31 @home-assistant/core /homeassistant/components/tesla_fleet/ @Bre77 /tests/components/tesla_fleet/ @Bre77 -/homeassistant/components/tesla_wall_connector/ @einarhauks -/tests/components/tesla_wall_connector/ @einarhauks +/homeassistant/components/tesla_wall_connector/ @einarhauks @sarabveer +/tests/components/tesla_wall_connector/ @einarhauks @sarabveer /homeassistant/components/teslemetry/ @Bre77 /tests/components/teslemetry/ @Bre77 /homeassistant/components/tessie/ @Bre77 diff --git a/homeassistant/components/anthropic/entity.py b/homeassistant/components/anthropic/entity.py index 4a4ffb86554d8c..df503af7b46e17 100644 --- a/homeassistant/components/anthropic/entity.py +++ b/homeassistant/components/anthropic/entity.py @@ -233,8 +233,9 @@ def _convert_content( # noqa: C901 """Transform HA chat_log content into Anthropic API format.""" messages: list[MessageParam] = [] container_id: str | None = None + contents = list(chat_content) - for content in chat_content: + for index, content in enumerate(contents): if isinstance(content, conversation.ToolResultContent): external_tool = True if content.tool_name == "web_search": @@ -322,14 +323,26 @@ def _convert_content( # noqa: C901 else: messages[-1]["content"].append(tool_result_block) # type: ignore[attr-defined] elif isinstance(content, conversation.UserContent): + has_text = bool(content.content.strip()) + # Attachments are only appended to the last message afterwards, so + # an empty message is only useful for attachments if it is last + has_attachments = bool(content.attachments) and index == len(contents) - 1 + if not has_text and not has_attachments: + # The API rejects whitespace-only text blocks and empty + # messages, so drop content that carries neither text nor + # usable attachments + continue # Combine consequent user messages if not messages or messages[-1]["role"] != "user": messages.append( MessageParam( role="user", - content=content.content, + content=content.content if has_text else [], ) ) + elif not has_text: + # Attachments are appended to the last user message later + continue elif isinstance(messages[-1]["content"], str): messages[-1]["content"] = [ TextBlockParam(type="text", text=messages[-1]["content"]), @@ -375,7 +388,7 @@ def _convert_content( # noqa: C901 ): container_id = content.native.container.id - if content.content: + if content.content and content.content.strip(): current_index = 0 for detail in ( content.native.citation_details @@ -455,7 +468,11 @@ def _convert_content( # noqa: C901 ] ) - if ( + if not messages[-1]["content"]: + # Drop assistant messages that ended up without any content + # (e.g. whitespace-only text): the API rejects empty messages + messages.pop() + elif ( isinstance(messages[-1]["content"], list) and len(messages[-1]["content"]) == 1 and messages[-1]["content"][0]["type"] == "text" diff --git a/homeassistant/components/aqvify/coordinator.py b/homeassistant/components/aqvify/coordinator.py index 10a77e3d02040d..65f064e29c3948 100644 --- a/homeassistant/components/aqvify/coordinator.py +++ b/homeassistant/components/aqvify/coordinator.py @@ -201,22 +201,15 @@ def __init__( self.api_client = api_client - @staticmethod - def _get_times() -> tuple[str, str]: - """Determine strings for time parameters for aggregated data from API.""" - date_time_fmt = "%Y-%m-%dT%H:%MZ" - base_time = utcnow() - timedelta(hours=1) - beg_time = base_time.replace(minute=0).strftime(date_time_fmt) - end_time = base_time.replace(minute=59).strftime(date_time_fmt) - return beg_time, end_time - @override async def _async_update_data(self) -> dict[str, AqvifyHourAggregatedValues]: """Fetch device state.""" devices = self.config_entry.runtime_data.coordinator.data.devices device_data: dict[str, AqvifyHourAggregatedValues] = {} - beg_time, end_time = self._get_times() + base_time = utcnow() - timedelta(hours=1) + beg_time = base_time.replace(minute=0, second=0, microsecond=0) + end_time = base_time.replace(minute=59, second=0, microsecond=0) for device in devices.devices.values(): device_key = device.device_key if TYPE_CHECKING: diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 536abf5b3dd56c..b5466dfb5f3ee6 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -2,7 +2,7 @@ "domain": "conversation", "name": "Conversation", "codeowners": ["@home-assistant/core", "@synesthesiam", "@arturpragacz"], - "dependencies": ["http", "intent"], + "dependencies": ["http", "intent", "llm"], "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "entity", "quality_scale": "internal", diff --git a/homeassistant/components/ecobee/__init__.py b/homeassistant/components/ecobee/__init__.py index e7462b40143ae3..1b0a4c031143fd 100644 --- a/homeassistant/components/ecobee/__init__.py +++ b/homeassistant/components/ecobee/__init__.py @@ -22,15 +22,26 @@ ConfigEntryError, ConfigEntryNotReady, ) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle from .const import _LOGGER, CONF_REFRESH_TOKEN, DOMAIN, PLATFORMS +from .services import async_setup_services MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=180) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + type EcobeeConfigEntry = ConfigEntry[EcobeeData] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the ecobee integration.""" + async_setup_services(hass) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: EcobeeConfigEntry) -> bool: """Set up ecobee via a config entry.""" api_key = entry.data.get(CONF_API_KEY) diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index 81902a2cd2cdad..dcc1e8f384163d 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -20,7 +20,6 @@ HVACMode, ) from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_TEMPERATURE, PRECISION_HALVES, PRECISION_TENTHS, @@ -28,7 +27,7 @@ STATE_ON, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import ( config_validation as cv, @@ -49,18 +48,20 @@ ECOBEE_MODEL_TO_NAME, MANUFACTURER, ) -from .util import ecobee_date, ecobee_time, is_indefinite_hold - -ATTR_COOL_TEMP = "cool_temp" -ATTR_END_DATE = "end_date" -ATTR_END_TIME = "end_time" -ATTR_FAN_MIN_ON_TIME = "fan_min_on_time" -ATTR_FAN_MODE = "fan_mode" -ATTR_HEAT_TEMP = "heat_temp" -ATTR_RESUME_ALL = "resume_all" -ATTR_START_DATE = "start_date" -ATTR_START_TIME = "start_time" -ATTR_VACATION_NAME = "vacation_name" +from .services import ( + ATTR_COOL_TEMP, + ATTR_END_DATE, + ATTR_END_TIME, + ATTR_FAN_MIN_ON_TIME, + ATTR_FAN_MODE, + ATTR_HEAT_TEMP, + ATTR_START_DATE, + ATTR_START_TIME, + ATTR_VACATION_NAME, + _async_get_thermostats, +) +from .util import is_indefinite_hold + ATTR_DST_ENABLED = "dst_enabled" ATTR_MIC_ENABLED = "mic_enabled" ATTR_AUTO_AWAY = "auto_away" @@ -68,7 +69,6 @@ ATTR_SENSOR_LIST = "device_ids" ATTR_PRESET_MODE = "preset_mode" -DEFAULT_RESUME_ALL = False PRESET_AWAY_INDEFINITELY = "away_indefinitely" PRESET_TEMPERATURE = "temp" PRESET_VACATION = "vacation" @@ -127,69 +127,11 @@ PRESET_HOLD_INDEFINITE: "indefinite", } -SERVICE_CREATE_VACATION = "create_vacation" -SERVICE_DELETE_VACATION = "delete_vacation" -SERVICE_RESUME_PROGRAM = "resume_program" -SERVICE_SET_FAN_MIN_ON_TIME = "set_fan_min_on_time" SERVICE_SET_DST_MODE = "set_dst_mode" SERVICE_SET_MIC_MODE = "set_mic_mode" SERVICE_SET_OCCUPANCY_MODES = "set_occupancy_modes" SERVICE_SET_SENSORS_USED_IN_CLIMATE = "set_sensors_used_in_climate" -DTGROUP_START_INCLUSIVE_MSG = ( - f"{ATTR_START_DATE} and {ATTR_START_TIME} must be specified together" -) - -DTGROUP_END_INCLUSIVE_MSG = ( - f"{ATTR_END_DATE} and {ATTR_END_TIME} must be specified together" -) - -CREATE_VACATION_SCHEMA = vol.Schema( - { - vol.Required(ATTR_ENTITY_ID): cv.entity_id, - vol.Required(ATTR_VACATION_NAME): vol.All(cv.string, vol.Length(max=12)), - vol.Required(ATTR_COOL_TEMP): vol.Coerce(float), - vol.Required(ATTR_HEAT_TEMP): vol.Coerce(float), - vol.Inclusive( - ATTR_START_DATE, "dtgroup_start", msg=DTGROUP_START_INCLUSIVE_MSG - ): ecobee_date, - vol.Inclusive( - ATTR_START_TIME, "dtgroup_start", msg=DTGROUP_START_INCLUSIVE_MSG - ): ecobee_time, - vol.Inclusive( - ATTR_END_DATE, "dtgroup_end", msg=DTGROUP_END_INCLUSIVE_MSG - ): ecobee_date, - vol.Inclusive( - ATTR_END_TIME, "dtgroup_end", msg=DTGROUP_END_INCLUSIVE_MSG - ): ecobee_time, - vol.Optional(ATTR_FAN_MODE, default="auto"): vol.Any("auto", "on"), - vol.Optional(ATTR_FAN_MIN_ON_TIME, default=0): vol.All( - int, vol.Range(min=0, max=60) - ), - } -) - -DELETE_VACATION_SCHEMA = vol.Schema( - { - vol.Required(ATTR_ENTITY_ID): cv.entity_id, - vol.Required(ATTR_VACATION_NAME): vol.All(cv.string, vol.Length(max=12)), - } -) - -RESUME_PROGRAM_SCHEMA = vol.Schema( - { - vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, - vol.Optional(ATTR_RESUME_ALL, default=DEFAULT_RESUME_ALL): cv.boolean, - } -) - -SET_FAN_MIN_ON_TIME_SCHEMA = vol.Schema( - { - vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, - vol.Required(ATTR_FAN_MIN_ON_TIME): vol.Coerce(int), - } -) - SUPPORT_FLAGS = ( ClimateEntityFeature.TARGET_TEMPERATURE @@ -226,96 +168,10 @@ async def async_setup_entry( entities.append(Thermostat(data, index, thermostat, hass)) async_add_entities(entities, True) + _async_get_thermostats(hass).extend(entities) platform = entity_platform.async_get_current_platform() - def create_vacation_service(service: ServiceCall) -> None: - """Create a vacation on the target thermostat.""" - entity_id = service.data[ATTR_ENTITY_ID] - - for thermostat in entities: - if thermostat.entity_id == entity_id: - thermostat.create_vacation(service.data) - thermostat.schedule_update_ha_state(True) - break - - def delete_vacation_service(service: ServiceCall) -> None: - """Delete a vacation on the target thermostat.""" - entity_id = service.data[ATTR_ENTITY_ID] - vacation_name = service.data[ATTR_VACATION_NAME] - - for thermostat in entities: - if thermostat.entity_id == entity_id: - thermostat.delete_vacation(vacation_name) - thermostat.schedule_update_ha_state(True) - break - - def fan_min_on_time_set_service(service: ServiceCall) -> None: - """Set the minimum fan on time on the target thermostats.""" - entity_id = service.data.get(ATTR_ENTITY_ID) - fan_min_on_time = service.data[ATTR_FAN_MIN_ON_TIME] - - if entity_id: - target_thermostats = [ - entity for entity in entities if entity.entity_id in entity_id - ] - else: - target_thermostats = entities - - for thermostat in target_thermostats: - thermostat.set_fan_min_on_time(str(fan_min_on_time)) - - thermostat.schedule_update_ha_state(True) - - def resume_program_set_service(service: ServiceCall) -> None: - """Resume the program on the target thermostats.""" - entity_id = service.data.get(ATTR_ENTITY_ID) - resume_all = service.data.get(ATTR_RESUME_ALL) - - if entity_id: - target_thermostats = [ - entity for entity in entities if entity.entity_id in entity_id - ] - else: - target_thermostats = entities - - for thermostat in target_thermostats: - thermostat.resume_program(resume_all) - - thermostat.schedule_update_ha_state(True) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_CREATE_VACATION, - create_vacation_service, - schema=CREATE_VACATION_SCHEMA, - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_DELETE_VACATION, - delete_vacation_service, - schema=DELETE_VACATION_SCHEMA, - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_SET_FAN_MIN_ON_TIME, - fan_min_on_time_set_service, - schema=SET_FAN_MIN_ON_TIME_SCHEMA, - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_RESUME_PROGRAM, - resume_program_set_service, - schema=RESUME_PROGRAM_SCHEMA, - ) - platform.async_register_entity_service( SERVICE_SET_DST_MODE, {vol.Required(ATTR_DST_ENABLED): cv.boolean}, diff --git a/homeassistant/components/ecobee/services.py b/homeassistant/components/ecobee/services.py new file mode 100644 index 00000000000000..86cdabe32055a8 --- /dev/null +++ b/homeassistant/components/ecobee/services.py @@ -0,0 +1,178 @@ +"""Services for the ecobee integration.""" + +from typing import TYPE_CHECKING + +import voluptuous as vol + +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN +from .util import ecobee_date, ecobee_time + +if TYPE_CHECKING: + from .climate import Thermostat + +ATTR_COOL_TEMP = "cool_temp" +ATTR_END_DATE = "end_date" +ATTR_END_TIME = "end_time" +ATTR_FAN_MIN_ON_TIME = "fan_min_on_time" +ATTR_FAN_MODE = "fan_mode" +ATTR_HEAT_TEMP = "heat_temp" +ATTR_RESUME_ALL = "resume_all" +ATTR_START_DATE = "start_date" +ATTR_START_TIME = "start_time" +ATTR_VACATION_NAME = "vacation_name" + +DEFAULT_RESUME_ALL = False + +DATA_THERMOSTATS = "thermostats" + +SERVICE_CREATE_VACATION = "create_vacation" +SERVICE_DELETE_VACATION = "delete_vacation" +SERVICE_RESUME_PROGRAM = "resume_program" +SERVICE_SET_FAN_MIN_ON_TIME = "set_fan_min_on_time" + +DTGROUP_START_INCLUSIVE_MSG = ( + f"{ATTR_START_DATE} and {ATTR_START_TIME} must be specified together" +) + +DTGROUP_END_INCLUSIVE_MSG = ( + f"{ATTR_END_DATE} and {ATTR_END_TIME} must be specified together" +) + +CREATE_VACATION_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_VACATION_NAME): vol.All(cv.string, vol.Length(max=12)), + vol.Required(ATTR_COOL_TEMP): vol.Coerce(float), + vol.Required(ATTR_HEAT_TEMP): vol.Coerce(float), + vol.Inclusive( + ATTR_START_DATE, "dtgroup_start", msg=DTGROUP_START_INCLUSIVE_MSG + ): ecobee_date, + vol.Inclusive( + ATTR_START_TIME, "dtgroup_start", msg=DTGROUP_START_INCLUSIVE_MSG + ): ecobee_time, + vol.Inclusive( + ATTR_END_DATE, "dtgroup_end", msg=DTGROUP_END_INCLUSIVE_MSG + ): ecobee_date, + vol.Inclusive( + ATTR_END_TIME, "dtgroup_end", msg=DTGROUP_END_INCLUSIVE_MSG + ): ecobee_time, + vol.Optional(ATTR_FAN_MODE, default="auto"): vol.Any("auto", "on"), + vol.Optional(ATTR_FAN_MIN_ON_TIME, default=0): vol.All( + int, vol.Range(min=0, max=60) + ), + } +) + +DELETE_VACATION_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_VACATION_NAME): vol.All(cv.string, vol.Length(max=12)), + } +) + +RESUME_PROGRAM_SCHEMA = vol.Schema( + { + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Optional(ATTR_RESUME_ALL, default=DEFAULT_RESUME_ALL): cv.boolean, + } +) + +SET_FAN_MIN_ON_TIME_SCHEMA = vol.Schema( + { + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_FAN_MIN_ON_TIME): vol.Coerce(int), + } +) + + +@callback +def _async_get_thermostats(hass: HomeAssistant) -> list[Thermostat]: + """Return loaded ecobee thermostat entities.""" + # pylint: disable-next=home-assistant-use-runtime-data + return hass.data[DOMAIN][DATA_THERMOSTATS] + + +def _create_vacation_service(call: ServiceCall) -> None: + """Create a vacation on the target thermostat.""" + for thermostat in _async_get_thermostats(call.hass): + if thermostat.entity_id == call.data[ATTR_ENTITY_ID]: + thermostat.create_vacation(call.data) + thermostat.schedule_update_ha_state(True) + break + + +def _delete_vacation_service(call: ServiceCall) -> None: + """Delete a vacation on the target thermostat.""" + for thermostat in _async_get_thermostats(call.hass): + if thermostat.entity_id == call.data[ATTR_ENTITY_ID]: + thermostat.delete_vacation(call.data[ATTR_VACATION_NAME]) + thermostat.schedule_update_ha_state(True) + break + + +def _fan_min_on_time_set_service(call: ServiceCall) -> None: + """Set the minimum fan on time on the target thermostats.""" + entity_id = call.data.get(ATTR_ENTITY_ID) + thermostats = _async_get_thermostats(call.hass) + if entity_id: + thermostats = [ + thermostat + for thermostat in thermostats + if thermostat.entity_id in entity_id + ] + + for thermostat in thermostats: + thermostat.set_fan_min_on_time(str(call.data[ATTR_FAN_MIN_ON_TIME])) + thermostat.schedule_update_ha_state(True) + + +def _resume_program_set_service(call: ServiceCall) -> None: + """Resume the program on the target thermostats.""" + entity_id = call.data.get(ATTR_ENTITY_ID) + thermostats = _async_get_thermostats(call.hass) + if entity_id: + thermostats = [ + thermostat + for thermostat in thermostats + if thermostat.entity_id in entity_id + ] + + for thermostat in thermostats: + thermostat.resume_program(call.data.get(ATTR_RESUME_ALL)) + thermostat.schedule_update_ha_state(True) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register ecobee services.""" + # pylint: disable-next=home-assistant-use-runtime-data + hass.data.setdefault(DOMAIN, {})[DATA_THERMOSTATS] = [] + + hass.services.async_register( + DOMAIN, + SERVICE_CREATE_VACATION, + _create_vacation_service, + schema=CREATE_VACATION_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_DELETE_VACATION, + _delete_vacation_service, + schema=DELETE_VACATION_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_SET_FAN_MIN_ON_TIME, + _fan_min_on_time_set_service, + schema=SET_FAN_MIN_ON_TIME_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_RESUME_PROGRAM, + _resume_program_set_service, + schema=RESUME_PROGRAM_SCHEMA, + ) diff --git a/homeassistant/components/elgato/light.py b/homeassistant/components/elgato/light.py index fb54f916114dae..3d5ba506df967b 100644 --- a/homeassistant/components/elgato/light.py +++ b/homeassistant/components/elgato/light.py @@ -100,7 +100,7 @@ def is_on(self) -> bool: async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the light.""" await self.coordinator.client.light(on=False) - await self.coordinator.async_request_refresh() + await self.coordinator.async_refresh() @elgato_exception_handler @override @@ -143,7 +143,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: saturation=saturation, temperature=temperature, ) - await self.coordinator.async_request_refresh() + await self.coordinator.async_refresh() @elgato_exception_handler async def async_identify(self) -> None: diff --git a/homeassistant/components/environment_canada/__init__.py b/homeassistant/components/environment_canada/__init__.py index 2572c735fa74d8..a43e350a45fc70 100644 --- a/homeassistant/components/environment_canada/__init__.py +++ b/homeassistant/components/environment_canada/__init__.py @@ -12,12 +12,16 @@ from homeassistant.helpers.typing import ConfigType from .const import ( + CONF_RADAR_DURATION, + CONF_RADAR_FPS, CONF_RADAR_LAYER, CONF_RADAR_LEGEND, CONF_RADAR_OPACITY, CONF_RADAR_RADIUS, CONF_RADAR_TIMESTAMP, CONF_STATION, + DEFAULT_RADAR_DURATION, + DEFAULT_RADAR_FPS, DEFAULT_RADAR_LAYER, DEFAULT_RADAR_LEGEND, DEFAULT_RADAR_OPACITY, @@ -75,6 +79,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ECConfigEntry) -> timestamp=options.get(CONF_RADAR_TIMESTAMP, DEFAULT_RADAR_TIMESTAMP), layer_opacity=int(options.get(CONF_RADAR_OPACITY, DEFAULT_RADAR_OPACITY)), radius=int(options.get(CONF_RADAR_RADIUS, DEFAULT_RADAR_RADIUS)), + loop_minutes=int(options.get(CONF_RADAR_DURATION, DEFAULT_RADAR_DURATION)), + fps=int(options.get(CONF_RADAR_FPS, DEFAULT_RADAR_FPS)), ) radar_coordinator = ECDataUpdateCoordinator( hass, config_entry, radar_data, "radar", DEFAULT_RADAR_UPDATE_INTERVAL diff --git a/homeassistant/components/environment_canada/config_flow.py b/homeassistant/components/environment_canada/config_flow.py index c5aadd3bd3e915..3a56dd576f6bcc 100644 --- a/homeassistant/components/environment_canada/config_flow.py +++ b/homeassistant/components/environment_canada/config_flow.py @@ -30,6 +30,8 @@ ) from .const import ( + CONF_RADAR_DURATION, + CONF_RADAR_FPS, CONF_RADAR_LAYER, CONF_RADAR_LEGEND, CONF_RADAR_OPACITY, @@ -37,6 +39,8 @@ CONF_RADAR_TIMESTAMP, CONF_STATION, CONF_TITLE, + DEFAULT_RADAR_DURATION, + DEFAULT_RADAR_FPS, DEFAULT_RADAR_LAYER, DEFAULT_RADAR_LEGEND, DEFAULT_RADAR_OPACITY, @@ -210,6 +214,22 @@ async def async_step_init( min=10, max=2000, step=10, unit_of_measurement="km" ) ), + vol.Required( + CONF_RADAR_DURATION, + default=options.get(CONF_RADAR_DURATION, DEFAULT_RADAR_DURATION), + ): NumberSelector( + NumberSelectorConfig( + min=0, max=180, step=5, unit_of_measurement="min" + ) + ), + vol.Required( + CONF_RADAR_FPS, + default=options.get(CONF_RADAR_FPS, DEFAULT_RADAR_FPS), + ): NumberSelector( + NumberSelectorConfig( + min=1, max=30, step=1, unit_of_measurement="fps" + ) + ), } ) diff --git a/homeassistant/components/environment_canada/const.py b/homeassistant/components/environment_canada/const.py index 39a9f3a949a7a1..52cf4b6564e443 100644 --- a/homeassistant/components/environment_canada/const.py +++ b/homeassistant/components/environment_canada/const.py @@ -12,6 +12,8 @@ CONF_RADAR_TIMESTAMP = "radar_timestamp" CONF_RADAR_OPACITY = "radar_opacity" CONF_RADAR_RADIUS = "radar_radius" +CONF_RADAR_DURATION = "radar_duration" +CONF_RADAR_FPS = "radar_fps" RADAR_LAYERS = ["rain", "snow", "precip_type"] @@ -22,3 +24,6 @@ DEFAULT_RADAR_TIMESTAMP = True DEFAULT_RADAR_OPACITY = 65 DEFAULT_RADAR_RADIUS = 200 +# 0 means use the full range of images Environment Canada reports as available. +DEFAULT_RADAR_DURATION = 0 +DEFAULT_RADAR_FPS = 5 diff --git a/homeassistant/components/environment_canada/strings.json b/homeassistant/components/environment_canada/strings.json index 93acc0ecc5589e..18491e942b2543 100644 --- a/homeassistant/components/environment_canada/strings.json +++ b/homeassistant/components/environment_canada/strings.json @@ -121,6 +121,8 @@ "step": { "init": { "data": { + "radar_duration": "Loop duration", + "radar_fps": "Loop frame rate", "radar_layer": "Radar type", "radar_legend": "Show legend", "radar_opacity": "Radar opacity", @@ -128,6 +130,8 @@ "radar_timestamp": "Show timestamp" }, "data_description": { + "radar_duration": "How far back the radar animation goes, in minutes (0 for the full available history)", + "radar_fps": "Frame rate of the radar animation", "radar_opacity": "Opacity of the radar layer overlay (0-100)", "radar_radius": "Radius of the radar map in kilometres" }, diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index ce7c3037c9e681..0182cacf9cfb70 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -4,14 +4,12 @@ from collections.abc import AsyncIterable from functools import partial import hashlib -import io from itertools import chain import json import logging from pathlib import Path import socket from typing import Any, cast, override -import wave from aioesphomeapi import ( MediaPlayerFormatPurpose, @@ -53,6 +51,7 @@ from .entry_data import ESPHomeConfigEntry from .enum_mapper import EsphomeEnumMapper from .ffmpeg_proxy import async_create_proxy_url +from .wav_parser import stream_wav PARALLEL_UPDATES = 0 @@ -726,36 +725,41 @@ async def _stream_tts_audio( ) return - data = b"".join([chunk async for chunk in tts_result.async_stream_result()]) + seconds_in_chunk = samples_per_chunk / sample_rate + start_time: float | None = None + audio_duration_sent = 0.0 + + async for chunk, is_last in stream_wav( + tts_result.async_stream_result(), + expected_format="pcm", + expected_channels=sample_channels, + expected_width=sample_width, + expected_sample_rate=sample_rate, + samples_per_chunk=samples_per_chunk, + ): + if not self._is_running: + break # type: ignore[unreachable] - with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file: - if ( - (wav_file.getframerate() != sample_rate) - or (wav_file.getsampwidth() != sample_width) - or (wav_file.getnchannels() != sample_channels) - ): - _LOGGER.error("Can only stream 16Khz 16-bit mono WAV") - return - - _LOGGER.debug("Streaming %s audio samples", wav_file.getnframes()) - - while self._is_running: - chunk = wav_file.readframes(samples_per_chunk) - if not chunk: - break - - if self._udp_server is not None: - self._udp_server.send_audio_bytes(chunk) - else: - self.cli.send_voice_assistant_audio(chunk) - - # Wait for 90% of the duration of the audio that was - # sent for it to be played. This will overrun the - # device's buffer for very long audio, so using a media - # player is preferred. - samples_in_chunk = len(chunk) // (sample_width * sample_channels) - seconds_in_chunk = samples_in_chunk / sample_rate - await asyncio.sleep(seconds_in_chunk * 0.9) + if start_time is None: + start_time = asyncio.get_running_loop().time() + + self._send_tts_audio(chunk) + + audio_duration_sent += seconds_in_chunk + + if is_last: + break + + # The ring buffer in the remote device is fixed at 512ms. + # We want to keep it at around 384ms (75% full) to prevent + # the buffer from overflowing or underflowing. + assert start_time is not None + elapsed = asyncio.get_running_loop().time() - start_time + if (wait_time := (audio_duration_sent - 0.384) - elapsed) > 0: + await asyncio.sleep(wait_time) + + except ValueError as err: + _LOGGER.error("Error streaming WAV: %s", err) except asyncio.CancelledError: return # Don't trigger state change finally: @@ -767,6 +771,13 @@ async def _stream_tts_audio( self.tts_response_finished() self._entry_data.async_set_assist_pipeline_state(False) + def _send_tts_audio(self, payload: bytes) -> None: + """Send TTS audio via API or UDP.""" + if self._udp_server is not None: + self._udp_server.send_audio_bytes(payload) + else: + self.cli.send_voice_assistant_audio(payload) + async def _wrap_audio_stream(self) -> AsyncIterable[bytes]: """Yield audio chunks from the queue until None.""" while True: diff --git a/homeassistant/components/esphome/wav_parser.py b/homeassistant/components/esphome/wav_parser.py new file mode 100644 index 00000000000000..892c89287f9f4d --- /dev/null +++ b/homeassistant/components/esphome/wav_parser.py @@ -0,0 +1,146 @@ +"""Helper to parse and stream WAV files.""" + +from collections.abc import AsyncIterable, AsyncIterator +import struct + + +class WAVHeaderParser: + """Helper to parse WAV headers from a byte buffer.""" + + def __init__( + self, + expected_channels: int, + expected_width: int, + expected_sample_rate: int, + ) -> None: + """Initialize the WAV header parser.""" + self.expected_channels = expected_channels + self.expected_width = expected_width + self.expected_sample_rate = expected_sample_rate + self.riff_checked = False + self.fmt_validated = False + self.data_bytes_remaining = 0 + self.found_data = False + + def parse(self, bytes_buffer: bytearray) -> bool: + """Parse headers from the buffer. Returns True if headers are fully parsed.""" + while True: + if not self.riff_checked: + if len(bytes_buffer) < 12: + return False + riff, _, wave_fmt = struct.unpack("<4sI4s", bytes_buffer[:12]) + if riff != b"RIFF" or wave_fmt != b"WAVE": + raise ValueError("Invalid WAV format: missing RIFF/WAVE header") + self.riff_checked = True + del bytes_buffer[:12] + + if len(bytes_buffer) < 8: + return False + + chunk_id, chunk_size = struct.unpack("<4sI", bytes_buffer[:8]) + + if chunk_id == b"fmt ": + if len(bytes_buffer) < 8 + chunk_size + (chunk_size & 1): + return False + + if chunk_size < 16: + raise ValueError(f"WAV fmt chunk too small: {chunk_size} bytes") + + ( + audio_format, + num_channels, + chunk_sample_rate, + _, + _, + bits_per_sample, + ) = struct.unpack(" AsyncIterator[tuple[bytes, bool]]: + """Parse a WAV stream, validate its header, and yield chunks of audio data.""" + if expected_format != "pcm": + raise ValueError(f"Unsupported expected format: {expected_format}") + + parser = WAVHeaderParser(expected_channels, expected_width, expected_sample_rate) + bytes_buffer = bytearray() + bytes_per_chunk_payload = samples_per_chunk * expected_width * expected_channels + pending_chunk: bytes | None = None + + async for chunk in stream: + bytes_buffer.extend(chunk) + + if not parser.found_data and not parser.parse(bytes_buffer): + continue + + while ( + parser.data_bytes_remaining >= bytes_per_chunk_payload + and len(bytes_buffer) >= bytes_per_chunk_payload + ): + payload = bytes(bytes_buffer[:bytes_per_chunk_payload]) + del bytes_buffer[:bytes_per_chunk_payload] + parser.data_bytes_remaining -= bytes_per_chunk_payload + + if pending_chunk is not None: + yield pending_chunk, False + + pending_chunk = payload + + if parser.data_bytes_remaining == 0: + yield pending_chunk, True + pending_chunk = None + return + + if not parser.found_data: + raise ValueError("Invalid WAV format: incomplete or missing data chunk") + + remaining_bytes_to_read = min(parser.data_bytes_remaining, len(bytes_buffer)) + if remaining_bytes_to_read > 0: + remaining = bytes(bytes_buffer[:remaining_bytes_to_read]) + if pending_chunk is not None: + yield pending_chunk, False + pending_chunk = remaining + + if pending_chunk is not None: + yield pending_chunk, True diff --git a/homeassistant/components/gree/climate.py b/homeassistant/components/gree/climate.py index 2e39f2e8226bb3..773bdd40899e47 100644 --- a/homeassistant/components/gree/climate.py +++ b/homeassistant/components/gree/climate.py @@ -26,10 +26,6 @@ PRESET_ECO, PRESET_NONE, PRESET_SLEEP, - SWING_BOTH, - SWING_HORIZONTAL, - SWING_OFF, - SWING_VERTICAL, ClimateEntity, ClimateEntityFeature, HVACMode, @@ -57,7 +53,7 @@ Mode.Fan: HVACMode.FAN_ONLY, Mode.Heat: HVACMode.HEAT, } -HVAC_MODES_REVERSE = {v: k for k, v in HVAC_MODES.items()} +HVAC_MODES_INVERSE = {v: k for k, v in HVAC_MODES.items()} PRESET_MODES = [ PRESET_ECO, # Power saving mode @@ -75,9 +71,38 @@ FanSpeed.MediumHigh: FAN_MEDIUM_HIGH, FanSpeed.High: FAN_HIGH, } -FAN_MODES_REVERSE = {v: k for k, v in FAN_MODES.items()} +FAN_MODES_INVERSE = {v: k for k, v in FAN_MODES.items()} + +VERTICAL_SWING_MODES: dict[str, VerticalSwing] = { + "default": VerticalSwing.Default, + "full_swing": VerticalSwing.FullSwing, + "fixed_upper": VerticalSwing.FixedUpper, + "fixed_upper_middle": VerticalSwing.FixedUpperMiddle, + "fixed_middle": VerticalSwing.FixedMiddle, + "fixed_lower_middle": VerticalSwing.FixedLowerMiddle, + "fixed_lower": VerticalSwing.FixedLower, + "swing_upper": VerticalSwing.SwingUpper, + "swing_upper_middle": VerticalSwing.SwingUpperMiddle, + "swing_middle": VerticalSwing.SwingMiddle, + "swing_lower_middle": VerticalSwing.SwingLowerMiddle, + "swing_lower": VerticalSwing.SwingLower, +} +VERTICAL_SWING_MODES_INVERSE: dict[VerticalSwing, str] = { + v: k for k, v in VERTICAL_SWING_MODES.items() +} -SWING_MODES = [SWING_OFF, SWING_VERTICAL, SWING_HORIZONTAL, SWING_BOTH] +HORIZONTAL_SWING_MODES: dict[str, HorizontalSwing] = { + "default": HorizontalSwing.Default, + "full_swing": HorizontalSwing.FullSwing, + "left": HorizontalSwing.Left, + "left_center": HorizontalSwing.LeftCenter, + "center": HorizontalSwing.Center, + "right_center": HorizontalSwing.RightCenter, + "right": HorizontalSwing.Right, +} +HORIZONTAL_SWING_MODES_INVERSE: dict[HorizontalSwing, str] = { + v: k for k, v in HORIZONTAL_SWING_MODES.items() +} async def async_setup_entry( @@ -109,15 +134,18 @@ class GreeClimateEntity(GreeEntity, ClimateEntity): | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.SWING_HORIZONTAL_MODE | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) _attr_target_temperature_step = TARGET_TEMPERATURE_STEP - _attr_hvac_modes = [*HVAC_MODES_REVERSE, HVACMode.OFF] + _attr_hvac_modes = [*HVAC_MODES_INVERSE, HVACMode.OFF] _attr_preset_modes = PRESET_MODES - _attr_fan_modes = [*FAN_MODES_REVERSE] - _attr_swing_modes = SWING_MODES + _attr_fan_modes = [*FAN_MODES_INVERSE] + _attr_swing_modes = [*VERTICAL_SWING_MODES] + _attr_swing_horizontal_modes = [*HORIZONTAL_SWING_MODES] _attr_name = None + _attr_translation_key = "climate" _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_min_temp = TEMP_MIN _attr_max_temp = TEMP_MAX @@ -189,7 +217,7 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: if not self.coordinator.device.power: self.coordinator.device.power = True - self.coordinator.device.mode = HVAC_MODES_REVERSE.get(hvac_mode) + self.coordinator.device.mode = HVAC_MODES_INVERSE.get(hvac_mode) await self.coordinator.push_state_update() self.async_write_ha_state() @@ -264,47 +292,60 @@ def fan_mode(self) -> str | None: @override async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" - if fan_mode not in FAN_MODES_REVERSE: + if fan_mode not in FAN_MODES_INVERSE: raise ValueError(f"Invalid fan mode: {fan_mode}") - self.coordinator.device.fan_speed = FAN_MODES_REVERSE.get(fan_mode) + self.coordinator.device.fan_speed = FAN_MODES_INVERSE.get(fan_mode) await self.coordinator.push_state_update() self.async_write_ha_state() @property @override - def swing_mode(self) -> str: - """Return the current swing mode for the device.""" - h_swing = self.coordinator.device.horizontal_swing == HorizontalSwing.FullSwing - v_swing = self.coordinator.device.vertical_swing == VerticalSwing.FullSwing - - if h_swing and v_swing: - return SWING_BOTH - if h_swing: - return SWING_HORIZONTAL - if v_swing: - return SWING_VERTICAL - return SWING_OFF + def swing_mode(self) -> str | None: + """Return the current vertical swing mode for the device.""" + try: + return VERTICAL_SWING_MODES_INVERSE.get( + VerticalSwing(self.coordinator.device.vertical_swing) + ) + except ValueError: + return None @override async def async_set_swing_mode(self, swing_mode: str) -> None: - """Set new target swing operation.""" - if swing_mode not in SWING_MODES: - raise ValueError(f"Invalid swing mode: {swing_mode}") - + """Set new target vertical swing operation.""" _LOGGER.debug( - "Setting swing mode to %s for device %s", + "Setting vertical swing mode to %s for device %s", swing_mode, self._attr_name, ) - self.coordinator.device.horizontal_swing = HorizontalSwing.Center - self.coordinator.device.vertical_swing = VerticalSwing.FixedMiddle - if swing_mode in (SWING_BOTH, SWING_HORIZONTAL): - self.coordinator.device.horizontal_swing = HorizontalSwing.FullSwing - if swing_mode in (SWING_BOTH, SWING_VERTICAL): - self.coordinator.device.vertical_swing = VerticalSwing.FullSwing + self.coordinator.device.vertical_swing = VERTICAL_SWING_MODES[swing_mode] + await self.coordinator.push_state_update() + self.async_write_ha_state() + + @property + @override + def swing_horizontal_mode(self) -> str | None: + """Return the current horizontal swing mode for the device.""" + try: + return HORIZONTAL_SWING_MODES_INVERSE.get( + HorizontalSwing(self.coordinator.device.horizontal_swing) + ) + except ValueError: + return None + + @override + async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None: + """Set new target horizontal swing operation.""" + _LOGGER.debug( + "Setting horizontal swing mode to %s for device %s", + swing_horizontal_mode, + self._attr_name, + ) + self.coordinator.device.horizontal_swing = HORIZONTAL_SWING_MODES[ + swing_horizontal_mode + ] await self.coordinator.push_state_update() self.async_write_ha_state() diff --git a/homeassistant/components/gree/strings.json b/homeassistant/components/gree/strings.json index 153919fb0dce34..cbf1cef49405e4 100644 --- a/homeassistant/components/gree/strings.json +++ b/homeassistant/components/gree/strings.json @@ -11,6 +11,39 @@ } }, "entity": { + "climate": { + "climate": { + "state_attributes": { + "swing_horizontal_mode": { + "state": { + "center": "Center", + "default": "Default", + "full_swing": "Full swing", + "left": "Left", + "left_center": "Left center", + "right": "Right", + "right_center": "Right center" + } + }, + "swing_mode": { + "state": { + "default": "Default", + "fixed_lower": "Fixed lower", + "fixed_lower_middle": "Fixed lower middle", + "fixed_middle": "Fixed middle", + "fixed_upper": "Fixed upper", + "fixed_upper_middle": "Fixed upper middle", + "full_swing": "Full swing", + "swing_lower": "Swing lower", + "swing_lower_middle": "Swing lower middle", + "swing_middle": "Swing middle", + "swing_upper": "Swing upper", + "swing_upper_middle": "Swing upper middle" + } + } + } + } + }, "switch": { "fresh_air": { "name": "Fresh air" diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index c800e03b0a8c1e..6b8dcba7cf9981 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -48,14 +48,13 @@ RestoreBackupState, WrittenBackup, async_get_manager as async_get_backup_manager, - suggested_filename as suggested_backup_filename, suggested_filename_from_name_date, ) from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.util import dt as dt_util +from homeassistant.util import dt as dt_util, slugify from homeassistant.util.enum import try_parse_enum from .const import DATA_CONFIG_STORE, DOMAIN, EVENT_SUPERVISOR_EVENT @@ -68,6 +67,16 @@ _LOGGER = logging.getLogger(__name__) +def _suggested_backup_filename(name: str, date: str) -> str: + """Suggest a filename for a Supervisor backup. + + Slugify the name so a display name with path separators (e.g. an add-on + named "Nabu Casa / Webhook Proxy") can't produce a filename Supervisor + rejects. The unsanitized name is still stored as the backup's display name. + """ + return suggested_filename_from_name_date(slugify(name), date) + + async def async_get_backup_agents( hass: HomeAssistant, **kwargs: Any, @@ -202,7 +211,7 @@ async def async_upload_backup( stream = await open_stream() upload_options = supervisor_backups.UploadBackupOptions( location={self.location}, - filename=PurePath(suggested_backup_filename(backup)), + filename=PurePath(_suggested_backup_filename(backup.name, backup.date)), ) async def stream_with_progress() -> AsyncIterator[bytes]: @@ -361,7 +370,7 @@ async def async_create_backup( date = dt_util.now().isoformat() extra_metadata = extra_metadata | {"supervisor.backup_request_date": date} - filename = suggested_filename_from_name_date(backup_name, date) + filename = _suggested_backup_filename(backup_name, date) try: backup = await self._client.backups.partial_backup( supervisor_backups.PartialBackupOptions( diff --git a/homeassistant/components/hassio/update.py b/homeassistant/components/hassio/update.py index ff5d25ba7df37d..afe62408ef5534 100644 --- a/homeassistant/components/hassio/update.py +++ b/homeassistant/components/hassio/update.py @@ -282,6 +282,7 @@ async def async_install( ) -> None: """Install an update.""" await update_os(self.hass, version, backup) + await self.coordinator.async_refresh() @override async def async_release_notes(self) -> str | None: diff --git a/homeassistant/components/history_stats/data.py b/homeassistant/components/history_stats/data.py index 1f132870af143c..76ed224b5f7806 100644 --- a/homeassistant/components/history_stats/data.py +++ b/homeassistant/components/history_stats/data.py @@ -197,8 +197,7 @@ async def _async_history_from_db( finally: self._query_count -= 1 self._history_current_period = [ - HistoryState(state.state, state.last_changed.timestamp()) - for state in states + HistoryState(state.state, state.last_changed_timestamp) for state in states ] def _state_changes_during_period( diff --git a/homeassistant/components/homeassistant/llm.py b/homeassistant/components/homeassistant/llm.py index f56df7e316168b..70a1f7557efb76 100644 --- a/homeassistant/components/homeassistant/llm.py +++ b/homeassistant/components/homeassistant/llm.py @@ -34,6 +34,36 @@ CALENDAR_DOMAIN = "calendar" SCRIPT_DOMAIN = "script" +DYNAMIC_CONTEXT_PROMPT = ( + "You ARE equipped to answer questions about the" + " current state of\n" + "the home using the `GetLiveContext` tool." + " This is a primary function." + " Do not state you lack the\n" + "functionality if the question requires live data.\n" + "If the user asks about device existence/type" + ' (e.g., "Do I have lights in the bedroom?"):' + " Answer\n" + "from the static context below.\n" + "If the user asks about the CURRENT state, value," + ' or mode (e.g., "Is the lock locked?",\n' + '"Is the fan on?",' + ' "What mode is the thermostat in?",' + ' "What is the temperature outside?"):\n' + " 1. Recognize this requires live data.\n" + " 2. You MUST call `GetLiveContext`." + " This tool will provide the needed real-time" + " information (like temperature from the local" + " weather, lock status, etc.).\n" + " 3. Use the tool's response** to answer the" + " user accurately" + ' (e.g., "The temperature outside is' + ' [value from tool].").\n' + "For general knowledge questions not about the" + " home: Answer truthfully from internal" + " knowledge.\n" +) + @callback def async_get_exposed_entities( @@ -290,10 +320,23 @@ async def async_call( def async_get_tools( hass: HomeAssistant, llm_context: LLMContext, api_id: str ) -> LLMTools | None: - """Return the GetLiveContext tool. - - The tool is always offered; it reports when nothing is exposed at call time. - """ + """Return the GetLiveContext tool and the smart home context prompt.""" if api_id != LLM_API_ASSIST: return None - return LLMTools(tools=[GetLiveContextTool()]) + + exposed_entities = async_get_exposed_entities( + hass, llm_context.assistant, include_state=False + ) + if exposed_entities: + prompt = "\n".join( + [ + DYNAMIC_CONTEXT_PROMPT, + "Static Context: An overview of the areas" + " and the devices in this smart home:", + yaml_util.dump(list(exposed_entities.values())), + ] + ) + else: + prompt = NO_ENTITIES_PROMPT + + return LLMTools(tools=[GetLiveContextTool()], prompt=prompt) diff --git a/homeassistant/components/homematicip_cloud/button.py b/homeassistant/components/homematicip_cloud/button.py index ae948ce5b4c9fb..3a017690ee2663 100644 --- a/homeassistant/components/homematicip_cloud/button.py +++ b/homeassistant/components/homematicip_cloud/button.py @@ -2,6 +2,7 @@ from typing import override +from homematicip.base.functionalChannels import AccessAuthorizationChannel from homematicip.device import WallMountedGarageDoorController from homeassistant.components.button import ButtonEntity @@ -12,11 +13,17 @@ from .hap import HomematicIPConfigEntry, HomematicipHAP -def _is_full_flush_lock_controller(device: object) -> bool: - """Return whether the device is an HmIP-FLC.""" - return getattr(device, "modelType", None) == "HmIP-FLC" and hasattr( - device, "send_start_impulse_async" - ) +def _door_opener_authorization_channel( + device: object, +) -> AccessAuthorizationChannel | None: + """Return the AccessAuthorizationChannel routed to the door opener.""" + for channel in getattr(device, "functionalChannels", []): + if ( + isinstance(channel, AccessAuthorizationChannel) + and getattr(channel, "channelRole", None) == "DOOR_OPENER_ACTUATOR" + ): + return channel + return None async def async_setup_entry( @@ -33,9 +40,10 @@ async def async_setup_entry( if isinstance(device, WallMountedGarageDoorController) ] entities.extend( - HomematicipFullFlushLockControllerButton(hap, device) + HomematicipFullFlushLockControllerButton(hap, device, auth_channel) for device in hap.home.devices - if _is_full_flush_lock_controller(device) + if getattr(device, "modelType", None) == "HmIP-FLC" + and (auth_channel := _door_opener_authorization_channel(device)) is not None ) async_add_entities(entities) @@ -57,14 +65,24 @@ async def async_press(self) -> None: class HomematicipFullFlushLockControllerButton(HomematicipGenericEntity, ButtonEntity): """Representation of the HomematicIP full flush lock controller opener.""" - def __init__(self, hap: HomematicipHAP, device) -> None: + def __init__( + self, + hap: HomematicipHAP, + device, + auth_channel: AccessAuthorizationChannel, + ) -> None: """Initialize the full flush lock controller opener button.""" super().__init__( hap, device, post="Door opener", feature_id="lock_opener_button" ) self._attr_icon = "mdi:door-open" + self._auth_channel = auth_channel @override async def async_press(self) -> None: - """Handle the button press.""" - await self._device.send_start_impulse_async() + """Pull the latch via the access-authorization channel. + + This is the only path non-admin clients may use; the door-switch + channel rejects them with CLIENT_ACCESS_DENIED. + """ + await self._auth_channel.async_pull_latch() diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index 06dd60bfe513c9..0d2124a7fed616 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -23,7 +23,6 @@ LightSensor, MotionDetectorIndoor, MotionDetectorOutdoor, - MotionDetectorPushButton, PassageDetector, PresenceDetectorIndoor, RoomControlDeviceAnalog, @@ -217,9 +216,6 @@ def get_device_handlers(hap: HomematicipHAP) -> dict[type, Callable]: MotionDetectorOutdoor: lambda device: [ HomematicipIlluminanceSensor(hap, device), ], - MotionDetectorPushButton: lambda device: [ - HomematicipIlluminanceSensor(hap, device), - ], PresenceDetectorIndoor: lambda device: [ HomematicipIlluminanceSensor(hap, device), ], diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json index 451202b73a6b29..6804e6ef202fa4 100644 --- a/homeassistant/components/honeywell/manifest.json +++ b/homeassistant/components/honeywell/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["somecomfort"], - "requirements": ["AIOSomecomfort==0.0.37"] + "requirements": ["AIOSomecomfort==0.0.38"] } diff --git a/homeassistant/components/imou/manifest.json b/homeassistant/components/imou/manifest.json index 7d25d81e79ede9..0ef2893fc15ab9 100644 --- a/homeassistant/components/imou/manifest.json +++ b/homeassistant/components/imou/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["pyimouapi==1.2.8"] + "requirements": ["pyimouapi==1.3.0"] } diff --git a/homeassistant/components/intent/llm.py b/homeassistant/components/intent/llm.py index 82b122a918902e..082987914327f4 100644 --- a/homeassistant/components/intent/llm.py +++ b/homeassistant/components/intent/llm.py @@ -8,7 +8,12 @@ from homeassistant.components.homeassistant import async_should_expose from homeassistant.components.llm import LLMTools from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import intent +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + floor_registry as fr, + intent, +) from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool from .timers import async_device_supports_timers @@ -33,19 +38,28 @@ intent.INTENT_TIMER_STATUS, ) +DEVICE_CONTROL_TOOL_USAGE_PROMPT = ( + "When controlling Home Assistant always call the intent tools. " + "Use HassTurnOn to lock and HassTurnOff to unlock a lock. " + "When controlling a device, prefer passing just name and domain. " + "When controlling an area, prefer passing just area name and domain." +) + @callback def async_get_tools( hass: HomeAssistant, llm_context: LLMContext, api_id: str ) -> LLMTools | None: - """Return LLM tools for the generic intents.""" + """Return the generic intent tools and the device control prompt.""" if api_id != LLM_API_ASSIST: return None + supports_timers = ( + llm_context.device_id is not None + and async_device_supports_timers(hass, llm_context.device_id) + ) wanted = set(LLM_INTENTS) - if llm_context.device_id and async_device_supports_timers( - hass, llm_context.device_id - ): + if supports_timers: wanted.update(TIMER_INTENTS) exposed_domains = { @@ -65,4 +79,40 @@ def async_get_tools( ] if not tools: return None - return LLMTools(tools=tools) + + # Only guide device control once something is exposed to control. + if not exposed_domains: + return LLMTools(tools=tools) + + # Tell the voice satellite which area it is in so generic commands target it. + floor: fr.FloorEntry | None = None + area: ar.AreaEntry | None = None + if llm_context.device_id and ( + device := dr.async_get(hass).async_get(llm_context.device_id) + ): + area_reg = ar.async_get(hass) + if device.area_id and (area := area_reg.async_get_area(device.area_id)): + if area.floor_id: + floor = fr.async_get(hass).async_get_floor(area.floor_id) + + if area and floor: + area_prompt = ( + f"You are in area {area.name} (floor {floor.name}) and all generic" + " commands like 'turn on the lights' should target this area." + ) + elif area: + area_prompt = ( + f"You are in area {area.name} and all generic commands like" + " 'turn on the lights' should target this area." + ) + else: + area_prompt = ( + "When a user asks to turn on all devices of a specific type, " + "ask the user to specify an area, unless there is only one device" + " of that type." + ) + + prompt_parts = [DEVICE_CONTROL_TOOL_USAGE_PROMPT, area_prompt] + if not supports_timers: + prompt_parts.append("This device is not able to start timers.") + return LLMTools(tools=tools, prompt="\n".join(prompt_parts)) diff --git a/homeassistant/components/kiosker/config_flow.py b/homeassistant/components/kiosker/config_flow.py index 85e43f280e0397..9e2d51ab6d8c0c 100644 --- a/homeassistant/components/kiosker/config_flow.py +++ b/homeassistant/components/kiosker/config_flow.py @@ -1,5 +1,6 @@ """Config flow for the Kiosker integration.""" +from collections.abc import Mapping import logging from typing import Any, override @@ -37,6 +38,11 @@ vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_SSL_VERIFY): bool, } ) +STEP_REAUTH_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_TOKEN): str, + } +) async def validate_input( @@ -121,6 +127,43 @@ async def async_step_user( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauth.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reauth confirmation.""" + errors: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() + + if user_input is not None: + config_data = { + **reauth_entry.data, + CONF_API_TOKEN: user_input[CONF_API_TOKEN], + } + validation_errors, device_id = await validate_input(self.hass, config_data) + if validation_errors: + errors.update(validation_errors) + else: + assert device_id is not None + await self.async_set_unique_id(device_id, raise_on_progress=False) + self._abort_if_unique_id_mismatch(reason="wrong_device") + return self.async_update_reload_and_abort( + reauth_entry, + data_updates={CONF_API_TOKEN: user_input[CONF_API_TOKEN]}, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_REAUTH_DATA_SCHEMA, + description_placeholders={"name": reauth_entry.title}, + errors=errors, + ) + @override async def async_step_zeroconf( self, discovery_info: ZeroconfServiceInfo diff --git a/homeassistant/components/kiosker/manifest.json b/homeassistant/components/kiosker/manifest.json index fc8c2ed911faa9..7256cda13b22e6 100644 --- a/homeassistant/components/kiosker/manifest.json +++ b/homeassistant/components/kiosker/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/kiosker", "integration_type": "device", "iot_class": "local_polling", - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["kiosker-python-api==1.2.9"], "zeroconf": ["_kiosker._tcp.local."] } diff --git a/homeassistant/components/kiosker/quality_scale.yaml b/homeassistant/components/kiosker/quality_scale.yaml index 92531353def71e..cd3cf4e2bb63f3 100644 --- a/homeassistant/components/kiosker/quality_scale.yaml +++ b/homeassistant/components/kiosker/quality_scale.yaml @@ -34,7 +34,7 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold diff --git a/homeassistant/components/kiosker/strings.json b/homeassistant/components/kiosker/strings.json index 2e700a3a43eb20..ea117b290b8abd 100644 --- a/homeassistant/components/kiosker/strings.json +++ b/homeassistant/components/kiosker/strings.json @@ -2,7 +2,9 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "wrong_device": "The device does not match the configured device." }, "error": { "bad_request": "Invalid request. Check your configuration.", @@ -13,6 +15,16 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "reauth_confirm": { + "data": { + "api_token": "[%key:common::config_flow::data::api_token%]" + }, + "data_description": { + "api_token": "The API token for the Kiosker App. This can be generated in the app API settings." + }, + "description": "Re-authenticate {name} with Home Assistant. Generate a new API token in the Kiosker app settings.", + "title": "Re-authenticate Kiosker" + }, "user": { "data": { "api_token": "[%key:common::config_flow::data::api_token%]", diff --git a/homeassistant/components/llm/__init__.py b/homeassistant/components/llm/__init__.py index 3f8d87eb2a054f..7b907762f89ca2 100644 --- a/homeassistant/components/llm/__init__.py +++ b/homeassistant/components/llm/__init__.py @@ -1,19 +1,21 @@ -"""The LLM integration. - -Owns the LLM tools platform: integrations contribute tools to the LLM APIs -through an ``/llm.py`` platform with an ``async_get_tools`` hook. -The platforms are loaded lazily and queried per request. The framework -(``Tool``, the APIs) lives in ``homeassistant.helpers.llm``. -""" +"""The LLM integration.""" from dataclasses import dataclass import logging -from typing import Protocol +from typing import Protocol, override from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.integration_platform import LazyIntegrationPlatforms -from homeassistant.helpers.llm import LLMContext, Tool +from homeassistant.helpers.llm import ( + API, + LLM_API_ASSIST, + APIInstance, + LLMContext, + Tool, + async_register_api, + selector_serializer, +) from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey @@ -54,6 +56,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.data[DATA_PLATFORMS] = LazyIntegrationPlatforms( hass, DOMAIN, _process_llm_tools_platform ) + async_register_api(hass, AssistAPI(hass)) return True @@ -86,3 +89,28 @@ async def async_get_tools( if result.prompt: prompts.append(result.prompt) return LLMTools(tools=tools, prompt="\n".join(prompts) if prompts else None) + + +class AssistAPI(API): + """API exposing Assist API to LLMs.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Init the class.""" + super().__init__( + hass=hass, + id=LLM_API_ASSIST, + name="Assist", + ) + + @override + async def async_get_api_instance(self, llm_context: LLMContext) -> APIInstance: + """Return the instance of the API.""" + llm_tools = await async_get_tools(self.hass, llm_context, self.id) + + return APIInstance( + api=self, + api_prompt=llm_tools.prompt or "", + llm_context=llm_context, + tools=llm_tools.tools, + custom_serializer=selector_serializer, + ) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index f505c3505017af..881aa71d11a19d 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -49,6 +49,7 @@ STATE_OFF, STATE_PLAYING, STATE_STANDBY, + EntityStateAttribute, ) from homeassistant.core import HomeAssistant, SupportsResponse from homeassistant.helpers import config_validation as cv @@ -543,7 +544,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): _entity_component_unrecorded_attributes = frozenset( { MediaPlayerEntityStateAttribute.ENTITY_PICTURE_LOCAL, - ATTR_ENTITY_PICTURE, + EntityStateAttribute.ENTITY_PICTURE, MediaPlayerEntityCapabilityAttribute.INPUT_SOURCE_LIST, MediaPlayerEntityStateAttribute.MEDIA_POSITION_UPDATED_AT, MediaPlayerEntityStateAttribute.MEDIA_POSITION, diff --git a/homeassistant/components/media_player/condition.py b/homeassistant/components/media_player/condition.py index d95fedf92cb2b2..639466a0dde92d 100644 --- a/homeassistant/components/media_player/condition.py +++ b/homeassistant/components/media_player/condition.py @@ -12,11 +12,10 @@ make_entity_state_condition, ) -from . import ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED -from .const import DOMAIN, MediaPlayerState +from .const import DOMAIN, MediaPlayerEntityStateAttribute, MediaPlayerState VOLUME_DOMAIN_SPECS: dict[str, DomainSpec] = { - DOMAIN: DomainSpec(value_source=ATTR_MEDIA_VOLUME_LEVEL), + DOMAIN: DomainSpec(value_source=MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL), } @@ -38,8 +37,10 @@ def _state_valid_since(self, state: State) -> datetime: def _has_volume_attributes(self, state: State) -> bool: """Check if the state has volume muted or volume level attributes.""" return ( - state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is not None - or state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None + state.attributes.get(MediaPlayerEntityStateAttribute.MEDIA_VOLUME_MUTED) + is not None + or state.attributes.get(MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL) + is not None ) @override @@ -50,8 +51,10 @@ def _should_include(self, state: State) -> bool: def _is_muted(self, state: State) -> bool: """Check if the media player is muted.""" return ( - state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is True - or state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) == 0 + state.attributes.get(MediaPlayerEntityStateAttribute.MEDIA_VOLUME_MUTED) + is True + or state.attributes.get(MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL) + == 0 ) @override @@ -96,7 +99,8 @@ def _should_include(self, state: State) -> bool: """Skip media players that do not expose a volume_level attribute.""" return ( super()._should_include(state) - and state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None + and state.attributes.get(MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL) + is not None ) diff --git a/homeassistant/components/media_player/reproduce_state.py b/homeassistant/components/media_player/reproduce_state.py index b4c2c4f821fb2d..a498966c2c9b3a 100644 --- a/homeassistant/components/media_player/reproduce_state.py +++ b/homeassistant/components/media_player/reproduce_state.py @@ -5,7 +5,6 @@ from typing import Any from homeassistant.const import ( - ATTR_SUPPORTED_FEATURES, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_STOP, @@ -19,6 +18,7 @@ STATE_ON, STATE_PAUSED, STATE_PLAYING, + EntityStateAttribute, ) from homeassistant.core import Context, HomeAssistant, State @@ -34,8 +34,19 @@ SERVICE_SELECT_SOUND_MODE, SERVICE_SELECT_SOURCE, MediaPlayerEntityFeature, + MediaPlayerEntityStateAttribute, ) +# Maps a state attribute to the service call argument used to restore it. +_STATE_ATTRIBUTE_TO_SERVICE_ARG: dict[MediaPlayerEntityStateAttribute, str] = { + MediaPlayerEntityStateAttribute.INPUT_SOURCE: ATTR_INPUT_SOURCE, + MediaPlayerEntityStateAttribute.SOUND_MODE: ATTR_SOUND_MODE, + MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL: ATTR_MEDIA_VOLUME_LEVEL, + MediaPlayerEntityStateAttribute.MEDIA_VOLUME_MUTED: ATTR_MEDIA_VOLUME_MUTED, + MediaPlayerEntityStateAttribute.MEDIA_CONTENT_TYPE: ATTR_MEDIA_CONTENT_TYPE, + MediaPlayerEntityStateAttribute.MEDIA_CONTENT_ID: ATTR_MEDIA_CONTENT_ID, +} + async def _async_reproduce_states( hass: HomeAssistant, @@ -46,14 +57,22 @@ async def _async_reproduce_states( ) -> None: """Reproduce component states.""" cur_state = hass.states.get(state.entity_id) - features = cur_state.attributes[ATTR_SUPPORTED_FEATURES] if cur_state else 0 + features = ( + cur_state.attributes[EntityStateAttribute.SUPPORTED_FEATURES] + if cur_state + else 0 + ) - async def call_service(service: str, keys: Iterable[str]) -> None: - """Call service with set of attributes given.""" + async def call_service( + service: str, attributes: Iterable[MediaPlayerEntityStateAttribute] + ) -> None: + """Call service with the given state attributes.""" data = {"entity_id": state.entity_id} - for key in keys: - if key in state.attributes: - data[key] = state.attributes[key] + for attribute in attributes: + if attribute in state.attributes: + data[_STATE_ATTRIBUTE_TO_SERVICE_ARG[attribute]] = state.attributes[ + attribute + ] await hass.services.async_call( DOMAIN, service, data, blocking=True, context=context @@ -79,42 +98,57 @@ async def call_service(service: str, keys: Iterable[str]) -> None: await call_service(SERVICE_TURN_ON, []) cur_state = hass.states.get(state.entity_id) - features = cur_state.attributes[ATTR_SUPPORTED_FEATURES] if cur_state else 0 + features = ( + cur_state.attributes[EntityStateAttribute.SUPPORTED_FEATURES] + if cur_state + else 0 + ) # First set source & sound mode to match the saved supported features if ( - ATTR_INPUT_SOURCE in state.attributes + MediaPlayerEntityStateAttribute.INPUT_SOURCE in state.attributes and features & MediaPlayerEntityFeature.SELECT_SOURCE ): - await call_service(SERVICE_SELECT_SOURCE, [ATTR_INPUT_SOURCE]) + await call_service( + SERVICE_SELECT_SOURCE, [MediaPlayerEntityStateAttribute.INPUT_SOURCE] + ) if ( - ATTR_SOUND_MODE in state.attributes + MediaPlayerEntityStateAttribute.SOUND_MODE in state.attributes and features & MediaPlayerEntityFeature.SELECT_SOUND_MODE ): - await call_service(SERVICE_SELECT_SOUND_MODE, [ATTR_SOUND_MODE]) + await call_service( + SERVICE_SELECT_SOUND_MODE, [MediaPlayerEntityStateAttribute.SOUND_MODE] + ) if ( - ATTR_MEDIA_VOLUME_LEVEL in state.attributes + MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL in state.attributes and features & MediaPlayerEntityFeature.VOLUME_SET ): - await call_service(SERVICE_VOLUME_SET, [ATTR_MEDIA_VOLUME_LEVEL]) + await call_service( + SERVICE_VOLUME_SET, [MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL] + ) if ( - ATTR_MEDIA_VOLUME_MUTED in state.attributes + MediaPlayerEntityStateAttribute.MEDIA_VOLUME_MUTED in state.attributes and features & MediaPlayerEntityFeature.VOLUME_MUTE ): - await call_service(SERVICE_VOLUME_MUTE, [ATTR_MEDIA_VOLUME_MUTED]) + await call_service( + SERVICE_VOLUME_MUTE, [MediaPlayerEntityStateAttribute.MEDIA_VOLUME_MUTED] + ) already_playing = False - if (ATTR_MEDIA_CONTENT_TYPE in state.attributes) and ( - ATTR_MEDIA_CONTENT_ID in state.attributes + if (MediaPlayerEntityStateAttribute.MEDIA_CONTENT_TYPE in state.attributes) and ( + MediaPlayerEntityStateAttribute.MEDIA_CONTENT_ID in state.attributes ): if features & MediaPlayerEntityFeature.PLAY_MEDIA: await call_service( SERVICE_PLAY_MEDIA, - [ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_CONTENT_ID], + [ + MediaPlayerEntityStateAttribute.MEDIA_CONTENT_TYPE, + MediaPlayerEntityStateAttribute.MEDIA_CONTENT_ID, + ], ) already_playing = True diff --git a/homeassistant/components/media_player/significant_change.py b/homeassistant/components/media_player/significant_change.py index f9d81b9135dc2e..c3320f3354ab34 100644 --- a/homeassistant/components/media_player/significant_change.py +++ b/homeassistant/components/media_player/significant_change.py @@ -8,21 +8,16 @@ check_valid_float, ) -from . import ( - ATTR_ENTITY_PICTURE_LOCAL, - ATTR_MEDIA_POSITION, - ATTR_MEDIA_POSITION_UPDATED_AT, - ATTR_MEDIA_VOLUME_LEVEL, - PROP_TO_ATTR, -) +from . import PROP_TO_ATTR +from .const import MediaPlayerEntityStateAttribute -INSIGNIFICANT_ATTRIBUTES: set[str] = { - ATTR_MEDIA_POSITION, - ATTR_MEDIA_POSITION_UPDATED_AT, +INSIGNIFICANT_ATTRIBUTES: set[MediaPlayerEntityStateAttribute] = { + MediaPlayerEntityStateAttribute.MEDIA_POSITION, + MediaPlayerEntityStateAttribute.MEDIA_POSITION_UPDATED_AT, } -SIGNIFICANT_ATTRIBUTES: set[str] = { - ATTR_ENTITY_PICTURE_LOCAL, +SIGNIFICANT_ATTRIBUTES: set[MediaPlayerEntityStateAttribute] = { + MediaPlayerEntityStateAttribute.ENTITY_PICTURE_LOCAL, *PROP_TO_ATTR.values(), } - INSIGNIFICANT_ATTRIBUTES @@ -49,7 +44,7 @@ def async_check_significant_change( changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} for attr_name in changed_attrs: - if attr_name != ATTR_MEDIA_VOLUME_LEVEL: + if attr_name != MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL: return True old_attr_value = old_attrs.get(attr_name) diff --git a/homeassistant/components/media_player/trigger.py b/homeassistant/components/media_player/trigger.py index 36c9e1c936ff01..0629797de7402c 100644 --- a/homeassistant/components/media_player/trigger.py +++ b/homeassistant/components/media_player/trigger.py @@ -14,11 +14,11 @@ make_entity_transition_trigger, ) -from . import ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, MediaPlayerState -from .const import DOMAIN +from . import MediaPlayerState +from .const import DOMAIN, MediaPlayerEntityStateAttribute VOLUME_DOMAIN_SPECS: dict[str, DomainSpec] = { - DOMAIN: DomainSpec(value_source=ATTR_MEDIA_VOLUME_LEVEL), + DOMAIN: DomainSpec(value_source=MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL), } @@ -31,8 +31,10 @@ class _MediaPlayerMutedStateTriggerBase(EntityTriggerBase): def _has_volume_attributes(self, state: State) -> bool: """Check if the state has volume muted or volume level attributes.""" return ( - state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is not None - or state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None + state.attributes.get(MediaPlayerEntityStateAttribute.MEDIA_VOLUME_MUTED) + is not None + or state.attributes.get(MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL) + is not None ) @override @@ -48,8 +50,10 @@ def _should_include(self, state: State) -> bool: def is_muted(self, state: State) -> bool: """Check if the media player is muted.""" return ( - state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is True - or state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) == 0 + state.attributes.get(MediaPlayerEntityStateAttribute.MEDIA_VOLUME_MUTED) + is True + or state.attributes.get(MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL) + == 0 ) @override @@ -109,7 +113,8 @@ def _should_include(self, state: State) -> bool: """ return ( super()._should_include(state) - and state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None + and state.attributes.get(MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL) + is not None ) diff --git a/homeassistant/components/melcloud_home/__init__.py b/homeassistant/components/melcloud_home/__init__.py index def9dc20f815d3..762b9784612089 100644 --- a/homeassistant/components/melcloud_home/__init__.py +++ b/homeassistant/components/melcloud_home/__init__.py @@ -11,6 +11,7 @@ PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.CLIMATE, + Platform.NUMBER, Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/melcloud_home/climate.py b/homeassistant/components/melcloud_home/climate.py index 510881e6bdda45..c4fc175d6892ee 100644 --- a/homeassistant/components/melcloud_home/climate.py +++ b/homeassistant/components/melcloud_home/climate.py @@ -198,6 +198,42 @@ def target_temperature(self) -> float | None: """Return the target temperature.""" return self.unit.set_temperature + @property + @override + def min_temp(self) -> float: + """Return the minimum temperature based on the current HVAC mode.""" + capabilities = self.unit.capabilities + if capabilities is not None: + hvac_mode = self.hvac_mode + if hvac_mode in (HVACMode.COOL, HVACMode.DRY): + if capabilities.min_temp_cool is not None: + return capabilities.min_temp_cool + elif hvac_mode == HVACMode.AUTO: + if capabilities.min_temp_auto is not None: + return capabilities.min_temp_auto + elif hvac_mode == HVACMode.HEAT: + if capabilities.min_temp_heat is not None: + return capabilities.min_temp_heat + return super().min_temp + + @property + @override + def max_temp(self) -> float: + """Return the maximum temperature based on the current HVAC mode.""" + capabilities = self.unit.capabilities + if capabilities is not None: + hvac_mode = self.hvac_mode + if hvac_mode in (HVACMode.COOL, HVACMode.DRY): + if capabilities.max_temp_cool is not None: + return capabilities.max_temp_cool + elif hvac_mode == HVACMode.AUTO: + if capabilities.max_temp_auto is not None: + return capabilities.max_temp_auto + elif hvac_mode == HVACMode.HEAT: + if capabilities.max_temp_heat is not None: + return capabilities.max_temp_heat + return super().max_temp + @property @override def hvac_mode(self) -> HVACMode: @@ -338,6 +374,36 @@ def target_temperature(self) -> float | None: else self.unit.set_temperature_zone2 ) + @property + @override + def min_temp(self) -> float: + """Return the minimum zone temperature.""" + capabilities = self.unit.capabilities + if capabilities is not None: + value = ( + capabilities.min_set_temperature_zone1 + if self.zone_number == 1 + else capabilities.min_set_temperature_zone2 + ) + if value is not None: + return value + return super().min_temp + + @property + @override + def max_temp(self) -> float: + """Return the maximum zone temperature.""" + capabilities = self.unit.capabilities + if capabilities is not None: + value = ( + capabilities.max_set_temperature_zone1 + if self.zone_number == 1 + else capabilities.max_set_temperature_zone2 + ) + if value is not None: + return value + return super().max_temp + @property @override def hvac_mode(self) -> HVACMode: diff --git a/homeassistant/components/melcloud_home/icons.json b/homeassistant/components/melcloud_home/icons.json index 2c7d55ef5f2750..dc98ac2263b0fa 100644 --- a/homeassistant/components/melcloud_home/icons.json +++ b/homeassistant/components/melcloud_home/icons.json @@ -29,6 +29,20 @@ } } }, + "number": { + "frost_protection_max_temp": { + "default": "mdi:thermometer" + }, + "frost_protection_min_temp": { + "default": "mdi:thermometer-low" + }, + "overheat_protection_max_temp": { + "default": "mdi:thermometer-high" + }, + "overheat_protection_min_temp": { + "default": "mdi:thermometer" + } + }, "sensor": { "room_temperature": { "default": "mdi:home-thermometer" diff --git a/homeassistant/components/melcloud_home/number.py b/homeassistant/components/melcloud_home/number.py new file mode 100644 index 00000000000000..65e2fbfd586891 --- /dev/null +++ b/homeassistant/components/melcloud_home/number.py @@ -0,0 +1,426 @@ +"""Number platform for MELCloud Home.""" + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any, override + +from aiomelcloudhome import ATAUnit, ATWUnit, MELCloudHome +from aiomelcloudhome.exceptions import ( + MelCloudHomeAuthenticationError, + MelCloudHomeConnectionError, + MelCloudHomeTimeoutError, +) + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) +from homeassistant.const import EntityCategory, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator +from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWUnitEntity + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class ATANumberEntityDescription(NumberEntityDescription): + """Class to hold MELCloud Home ATA number description.""" + + available_fn: Callable[[ATAUnit], bool] + value_fn: Callable[[ATAUnit], float | None] + set_value_fn: Callable[[MELCloudHome, ATAUnit, float], Coroutine[Any, Any, None]] + validate_fn: Callable[[ATAUnit, float], str | None] | None = None + + +@dataclass(frozen=True, kw_only=True) +class ATWNumberEntityDescription(NumberEntityDescription): + """Class to hold MELCloud Home ATW number description.""" + + available_fn: Callable[[ATWUnit], bool] + value_fn: Callable[[ATWUnit], float | None] + set_value_fn: Callable[[MELCloudHome, ATWUnit, float], Coroutine[Any, Any, None]] + validate_fn: Callable[[ATWUnit, float], str | None] | None = None + + +ATA_NUMBERS: tuple[ATANumberEntityDescription, ...] = ( + ATANumberEntityDescription( + key="frost_protection_min_temp", + translation_key="frost_protection_min_temp", + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_category=EntityCategory.CONFIG, + native_min_value=0.0, + native_max_value=30.0, + native_step=0.5, + available_fn=lambda unit: ( + unit.frost_protection is not None and unit.frost_protection.enabled + ), + value_fn=lambda unit: ( + unit.frost_protection.min if unit.frost_protection else None + ), + set_value_fn=lambda client, unit, value: client.set_frost_protection( + enabled=unit.frost_protection.enabled if unit.frost_protection else False, + min_temp=value, + max_temp=unit.frost_protection.max if unit.frost_protection else 0.0, + ata_unit_ids=[unit.id], + ), + validate_fn=lambda unit, value: ( + "temperature_min_exceeds_max" + if unit.frost_protection and value >= unit.frost_protection.max + else None + ), + ), + ATANumberEntityDescription( + key="frost_protection_max_temp", + translation_key="frost_protection_max_temp", + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_category=EntityCategory.CONFIG, + native_min_value=0.0, + native_max_value=30.0, + native_step=0.5, + available_fn=lambda unit: ( + unit.frost_protection is not None and unit.frost_protection.enabled + ), + value_fn=lambda unit: ( + unit.frost_protection.max if unit.frost_protection else None + ), + set_value_fn=lambda client, unit, value: client.set_frost_protection( + enabled=unit.frost_protection.enabled if unit.frost_protection else False, + min_temp=unit.frost_protection.min if unit.frost_protection else 0.0, + max_temp=value, + ata_unit_ids=[unit.id], + ), + validate_fn=lambda unit, value: ( + "temperature_max_below_min" + if unit.frost_protection and value <= unit.frost_protection.min + else None + ), + ), + ATANumberEntityDescription( + key="overheat_protection_min_temp", + translation_key="overheat_protection_min_temp", + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_category=EntityCategory.CONFIG, + native_min_value=31.0, + native_max_value=40.0, + native_step=0.5, + available_fn=lambda unit: ( + unit.overheat_protection is not None and unit.overheat_protection.enabled + ), + value_fn=lambda unit: ( + unit.overheat_protection.min if unit.overheat_protection else None + ), + set_value_fn=lambda client, unit, value: client.set_overheat_protection( + enabled=unit.overheat_protection.enabled + if unit.overheat_protection + else False, + min_temp=value, + max_temp=unit.overheat_protection.max if unit.overheat_protection else 0.0, + ata_unit_ids=[unit.id], + ), + validate_fn=lambda unit, value: ( + "temperature_min_exceeds_max" + if unit.overheat_protection and value >= unit.overheat_protection.max + else None + ), + ), + ATANumberEntityDescription( + key="overheat_protection_max_temp", + translation_key="overheat_protection_max_temp", + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_category=EntityCategory.CONFIG, + native_min_value=31.0, + native_max_value=40.0, + native_step=0.5, + available_fn=lambda unit: ( + unit.overheat_protection is not None and unit.overheat_protection.enabled + ), + value_fn=lambda unit: ( + unit.overheat_protection.max if unit.overheat_protection else None + ), + set_value_fn=lambda client, unit, value: client.set_overheat_protection( + enabled=unit.overheat_protection.enabled + if unit.overheat_protection + else False, + min_temp=unit.overheat_protection.min if unit.overheat_protection else 0.0, + max_temp=value, + ata_unit_ids=[unit.id], + ), + validate_fn=lambda unit, value: ( + "temperature_max_below_min" + if unit.overheat_protection and value <= unit.overheat_protection.min + else None + ), + ), +) + +ATW_NUMBERS: tuple[ATWNumberEntityDescription, ...] = ( + ATWNumberEntityDescription( + key="frost_protection_min_temp", + translation_key="frost_protection_min_temp", + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_category=EntityCategory.CONFIG, + native_min_value=0.0, + native_max_value=30.0, + native_step=0.5, + available_fn=lambda unit: ( + unit.frost_protection is not None and unit.frost_protection.enabled + ), + value_fn=lambda unit: ( + unit.frost_protection.min if unit.frost_protection else None + ), + set_value_fn=lambda client, unit, value: client.set_frost_protection( + enabled=unit.frost_protection.enabled if unit.frost_protection else False, + min_temp=value, + max_temp=unit.frost_protection.max if unit.frost_protection else 0.0, + atw_unit_ids=[unit.id], + ), + validate_fn=lambda unit, value: ( + "temperature_min_exceeds_max" + if unit.frost_protection and value >= unit.frost_protection.max + else None + ), + ), + ATWNumberEntityDescription( + key="frost_protection_max_temp", + translation_key="frost_protection_max_temp", + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_category=EntityCategory.CONFIG, + native_min_value=0.0, + native_max_value=30.0, + native_step=0.5, + available_fn=lambda unit: ( + unit.frost_protection is not None and unit.frost_protection.enabled + ), + value_fn=lambda unit: ( + unit.frost_protection.max if unit.frost_protection else None + ), + set_value_fn=lambda client, unit, value: client.set_frost_protection( + enabled=unit.frost_protection.enabled if unit.frost_protection else False, + min_temp=unit.frost_protection.min if unit.frost_protection else 0.0, + max_temp=value, + atw_unit_ids=[unit.id], + ), + validate_fn=lambda unit, value: ( + "temperature_max_below_min" + if unit.frost_protection and value <= unit.frost_protection.min + else None + ), + ), + ATWNumberEntityDescription( + key="overheat_protection_min_temp", + translation_key="overheat_protection_min_temp", + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_category=EntityCategory.CONFIG, + native_min_value=20.0, + native_max_value=60.0, + native_step=0.5, + available_fn=lambda unit: ( + unit.overheat_protection is not None and unit.overheat_protection.enabled + ), + value_fn=lambda unit: ( + unit.overheat_protection.min if unit.overheat_protection else None + ), + set_value_fn=lambda client, unit, value: client.set_overheat_protection( + enabled=unit.overheat_protection.enabled + if unit.overheat_protection + else False, + min_temp=value, + max_temp=unit.overheat_protection.max if unit.overheat_protection else 0.0, + atw_unit_ids=[unit.id], + ), + validate_fn=lambda unit, value: ( + "temperature_min_exceeds_max" + if unit.overheat_protection and value >= unit.overheat_protection.max + else None + ), + ), + ATWNumberEntityDescription( + key="overheat_protection_max_temp", + translation_key="overheat_protection_max_temp", + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_category=EntityCategory.CONFIG, + native_min_value=20.0, + native_max_value=60.0, + native_step=0.5, + available_fn=lambda unit: ( + unit.overheat_protection is not None and unit.overheat_protection.enabled + ), + value_fn=lambda unit: ( + unit.overheat_protection.max if unit.overheat_protection else None + ), + set_value_fn=lambda client, unit, value: client.set_overheat_protection( + enabled=unit.overheat_protection.enabled + if unit.overheat_protection + else False, + min_temp=unit.overheat_protection.min if unit.overheat_protection else 0.0, + max_temp=value, + atw_unit_ids=[unit.id], + ), + validate_fn=lambda unit, value: ( + "temperature_max_below_min" + if unit.overheat_protection and value <= unit.overheat_protection.min + else None + ), + ), +) + + +async def _perform_action( + coordinator: MelCloudHomeCoordinator, + coroutine: Coroutine[Any, Any, None], +) -> None: + """Perform a MELCloud Home action with error handling and coordinator refresh.""" + try: + await coroutine + except MelCloudHomeAuthenticationError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="invalid_auth", + ) from err + except MelCloudHomeConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + except MelCloudHomeTimeoutError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="timeout_connect", + ) from err + else: + await coordinator.async_request_refresh() + + +async def async_setup_entry( + hass: HomeAssistant, + entry: MelCloudHomeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up MELCloud Home numbers.""" + coordinator = entry.runtime_data + + def _async_add_new_ata_units(units: list[ATAUnit]) -> None: + async_add_entities( + ATANumber(coordinator, entity_description, unit) + for entity_description in ATA_NUMBERS + for unit in units + ) + + def _async_add_new_atw_units(units: list[ATWUnit]) -> None: + async_add_entities( + ATWNumber(coordinator, entity_description, unit) + for entity_description in ATW_NUMBERS + for unit in units + ) + + coordinator.new_ata_callbacks.append(_async_add_new_ata_units) + coordinator.new_atw_callbacks.append(_async_add_new_atw_units) + + _async_add_new_ata_units(list(coordinator.ata_units.values())) + _async_add_new_atw_units(list(coordinator.atw_units.values())) + + +class ATANumber(MelCloudHomeATAUnitEntity, NumberEntity): + """Representation of a MELCloud Home ATA number.""" + + entity_description: ATANumberEntityDescription + + def __init__( + self, + coordinator: MelCloudHomeCoordinator, + entity_description: ATANumberEntityDescription, + unit: ATAUnit, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator, unit) + self.entity_description = entity_description + self._attr_unique_id = f"{unit.id}_{entity_description.key}" + + @property + @override + def available(self) -> bool: + """Return if the entity is available.""" + return super().available and self.entity_description.available_fn(self.unit) + + @property + @override + def native_value(self) -> float | None: + """Return the current value.""" + return self.entity_description.value_fn(self.unit) + + @override + async def async_set_native_value(self, value: float) -> None: + """Set the protection temperature threshold.""" + if self.entity_description.validate_fn and ( + error_key := self.entity_description.validate_fn(self.unit, value) + ): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=error_key, + ) + await _perform_action( + self.coordinator, + self.entity_description.set_value_fn( + self.coordinator.client, self.unit, value + ), + ) + + +class ATWNumber(MelCloudHomeATWUnitEntity, NumberEntity): + """Representation of a MELCloud Home ATW number.""" + + entity_description: ATWNumberEntityDescription + + def __init__( + self, + coordinator: MelCloudHomeCoordinator, + entity_description: ATWNumberEntityDescription, + unit: ATWUnit, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator, unit) + self.entity_description = entity_description + self._attr_unique_id = f"{unit.id}_{entity_description.key}" + + @property + @override + def available(self) -> bool: + """Return if the entity is available.""" + return super().available and self.entity_description.available_fn(self.unit) + + @property + @override + def native_value(self) -> float | None: + """Return the current value.""" + return self.entity_description.value_fn(self.unit) + + @override + async def async_set_native_value(self, value: float) -> None: + """Set the protection temperature threshold.""" + if self.entity_description.validate_fn and ( + error_key := self.entity_description.validate_fn(self.unit, value) + ): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=error_key, + ) + await _perform_action( + self.coordinator, + self.entity_description.set_value_fn( + self.coordinator.client, self.unit, value + ), + ) diff --git a/homeassistant/components/melcloud_home/strings.json b/homeassistant/components/melcloud_home/strings.json index e424162368d12d..330bfd52224a8b 100644 --- a/homeassistant/components/melcloud_home/strings.json +++ b/homeassistant/components/melcloud_home/strings.json @@ -108,6 +108,20 @@ } } }, + "number": { + "frost_protection_max_temp": { + "name": "Frost protection maximum temperature" + }, + "frost_protection_min_temp": { + "name": "Frost protection minimum temperature" + }, + "overheat_protection_max_temp": { + "name": "Overheat protection maximum temperature" + }, + "overheat_protection_min_temp": { + "name": "Overheat protection minimum temperature" + } + }, "sensor": { "energy_consumed": { "name": "Energy consumed (monthly)" @@ -141,6 +155,12 @@ "invalid_auth": { "message": "An error occurred while trying to authenticate" }, + "temperature_max_below_min": { + "message": "The maximum temperature must be higher than the minimum temperature." + }, + "temperature_min_exceeds_max": { + "message": "The minimum temperature must be lower than the maximum temperature." + }, "timeout_connect": { "message": "Timeout while communicating with MELCloud Home API" } diff --git a/homeassistant/components/netatmo/config_flow.py b/homeassistant/components/netatmo/config_flow.py index 0e4c74a6d0005c..3693bb76105cac 100644 --- a/homeassistant/components/netatmo/config_flow.py +++ b/homeassistant/components/netatmo/config_flow.py @@ -7,7 +7,7 @@ import voluptuous as vol -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_SHOW_ON_MAP, CONF_UUID from homeassistant.core import callback from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv @@ -62,10 +62,6 @@ def extra_authorize_data(self) -> dict: async def async_step_user(self, user_input: dict | None = None) -> ConfigFlowResult: """Handle a flow start.""" await self.async_set_unique_id(DOMAIN) - - if self.source != SOURCE_REAUTH and self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") - return await super().async_step_user(user_input) async def async_step_reauth( diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index 6d6aea230f1870..83375b245ee213 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -12,5 +12,6 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pyatmo"], - "requirements": ["pyatmo==9.4.0"] + "requirements": ["pyatmo==9.4.0"], + "single_config_entry": true } diff --git a/homeassistant/components/netgear/const.py b/homeassistant/components/netgear/const.py index 6221de06693ece..f05a571755af1c 100644 --- a/homeassistant/components/netgear/const.py +++ b/homeassistant/components/netgear/const.py @@ -50,6 +50,7 @@ # update method V2 models MODELS_V2 = [ "Orbi", + "RBE", "RBK", "RBR", "RBS", diff --git a/homeassistant/components/nobo_hub/__init__.py b/homeassistant/components/nobo_hub/__init__.py index 59f3ad1789b8df..daf5611f0424f1 100644 --- a/homeassistant/components/nobo_hub/__init__.py +++ b/homeassistant/components/nobo_hub/__init__.py @@ -1,5 +1,7 @@ """The Nobø Ecohub integration.""" +import logging + from pynobo import nobo from homeassistant.config_entries import ConfigEntry @@ -25,6 +27,8 @@ NOBO_MANUFACTURER, ) +_LOGGER = logging.getLogger(__name__) + PLATFORMS = [Platform.CLIMATE, Platform.SELECT, Platform.SENSOR] type NoboHubConfigEntry = ConfigEntry[nobo] @@ -80,6 +84,18 @@ async def _async_close(event): entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_close) ) + + def _log_connection_state(_hub: nobo, connected: bool) -> None: + """Log hub connection-state transitions.""" + if connected: + _LOGGER.info("Reconnected to Nobø Ecohub %s", serial) + else: + _LOGGER.info("Lost connection to Nobø Ecohub %s", serial) + + hub.register_connection_callback(_log_connection_state) + entry.async_on_unload( + lambda: hub.deregister_connection_callback(_log_connection_state) + ) entry.runtime_data = hub device_registry = dr.async_get(hass) diff --git a/homeassistant/components/nobo_hub/climate.py b/homeassistant/components/nobo_hub/climate.py index 6cb8449a582896..2ddd05e1bbd306 100644 --- a/homeassistant/components/nobo_hub/climate.py +++ b/homeassistant/components/nobo_hub/climate.py @@ -161,15 +161,18 @@ async def async_update(self) -> None: """Fetch new state data for this zone.""" self._read_state() + @property + @override + def available(self) -> bool: + """Available when the hub is connected and the zone still exists.""" + return super().available and self._id in self._nobo.zones + @callback @override def _read_state(self) -> None: - """Copy the current hub state onto the entity attributes.""" - if self._id not in self._nobo.zones: - # Zone removed via the Nobø app; mark unavailable. - self._attr_available = False + """Read the current state from the hub. These are only local calls.""" + if not self.available: return - self._attr_available = True state = self._nobo.get_current_zone_mode(self._id, dt_util.now()) self._attr_hvac_mode = HVACMode.AUTO self._attr_preset_mode = PRESET_NONE diff --git a/homeassistant/components/nobo_hub/entity.py b/homeassistant/components/nobo_hub/entity.py index 22445ed7150e8a..7ad26edb40bf2d 100644 --- a/homeassistant/components/nobo_hub/entity.py +++ b/homeassistant/components/nobo_hub/entity.py @@ -17,16 +17,21 @@ class NoboBaseEntity(Entity): def __init__(self, hub: nobo) -> None: """Initialize the entity.""" self._nobo = hub + self._attr_available = hub.connected @override async def async_added_to_hass(self) -> None: - """Register callback with hub.""" + """Register callbacks with hub.""" await super().async_added_to_hass() self._nobo.register_callback(self._handle_hub_update) + self._nobo.register_connection_callback(self._handle_hub_connection) + # Resync in case the state changed between __init__ and callback registration. + self._attr_available = self._nobo.connected @override async def async_will_remove_from_hass(self) -> None: - """Deregister callback from hub.""" + """Deregister callbacks from hub.""" + self._nobo.deregister_connection_callback(self._handle_hub_connection) self._nobo.deregister_callback(self._handle_hub_update) await super().async_will_remove_from_hass() @@ -36,6 +41,16 @@ def _handle_hub_update(self, _hub: nobo) -> None: self._read_state() self.async_write_ha_state() + @callback + def _handle_hub_connection(self, _hub: nobo, connected: bool) -> None: + """Handle a connection-state transition from the hub.""" + self._attr_available = connected + if connected: + # Refresh state values so the first state write after reconnect + # carries fresh data, not whatever was cached pre-disconnect. + self._read_state() + self.async_write_ha_state() + @callback def _read_state(self) -> None: """Copy the current hub state from the pynobo client onto the entity attributes. diff --git a/homeassistant/components/nobo_hub/quality_scale.yaml b/homeassistant/components/nobo_hub/quality_scale.yaml index bd6a427a0c28aa..72de24495175eb 100644 --- a/homeassistant/components/nobo_hub/quality_scale.yaml +++ b/homeassistant/components/nobo_hub/quality_scale.yaml @@ -34,9 +34,9 @@ rules: config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done - entity-unavailable: todo + entity-unavailable: done integration-owner: done - log-when-unavailable: todo + log-when-unavailable: done parallel-updates: done reauthentication-flow: status: exempt diff --git a/homeassistant/components/nobo_hub/select.py b/homeassistant/components/nobo_hub/select.py index a4850fc6c76d4d..9c8313ebdfc534 100644 --- a/homeassistant/components/nobo_hub/select.py +++ b/homeassistant/components/nobo_hub/select.py @@ -143,15 +143,18 @@ async def async_update(self) -> None: """Fetch new state data for this zone.""" self._read_state() + @property + @override + def available(self) -> bool: + """Available when the hub is connected and the zone still exists.""" + return super().available and self._id in self._nobo.zones + @callback @override def _read_state(self) -> None: - """Copy the current hub state onto the entity attributes.""" - if self._id not in self._nobo.zones: - # Zone removed via the Nobø app; mark unavailable. - self._attr_available = False + """Read the current state from the hub. These are only local calls.""" + if not self.available: return - self._attr_available = True self._profiles = { profile["week_profile_id"]: profile["name"].replace("\xa0", " ") for profile in self._nobo.week_profiles.values() diff --git a/homeassistant/components/nobo_hub/sensor.py b/homeassistant/components/nobo_hub/sensor.py index c82718449035c6..8ebea0b63419db 100644 --- a/homeassistant/components/nobo_hub/sensor.py +++ b/homeassistant/components/nobo_hub/sensor.py @@ -69,14 +69,17 @@ def __init__(self, serial: str, hub: nobo) -> None: ) self._read_state() + @property + @override + def available(self) -> bool: + """Available when the hub is connected and the component still exists.""" + return super().available and self._id in self._nobo.components + @callback @override def _read_state(self) -> None: - """Copy the current hub state onto the entity attributes.""" - if self._id not in self._nobo.components: - # Component removed via the Nobø app; mark unavailable. - self._attr_available = False + """Read the current state from the hub. This is a local call.""" + if not self.available: return - self._attr_available = True value = self._nobo.get_current_component_temperature(self._id) self._attr_native_value = None if value is None else float(value) diff --git a/homeassistant/components/overkiz/number.py b/homeassistant/components/overkiz/number.py index 72198368f18bec..b20637b95d0021 100644 --- a/homeassistant/components/overkiz/number.py +++ b/homeassistant/components/overkiz/number.py @@ -2,7 +2,7 @@ import asyncio from collections.abc import Awaitable, Callable -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import cast, override from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState @@ -19,6 +19,7 @@ from . import OverkizDataConfigEntry from .const import IGNORED_OVERKIZ_DEVICES from .coordinator import OverkizDataUpdateCoordinator +from .cover import SUPPORTED_DEVICES as SUPPORTED_COVER_DEVICES from .entity import OverkizDescriptiveEntity BOOST_MODE_DURATION_DELAY = 1 @@ -206,15 +207,28 @@ async def async_setup_entry( ): continue - entities.extend( - OverkizNumber( - device.device_url, - data.coordinator, - description, + for state in device.definition.states: + if not (description := SUPPORTED_STATES.get(state)): + continue + + # Mirror the cover's position inversion. + if description.key == OverkizState.CORE_MEMORIZED_1_POSITION and ( + cover_description := ( + SUPPORTED_COVER_DEVICES.get(device.widget) + or SUPPORTED_COVER_DEVICES.get(device.ui_class) + ) + ): + description = replace( + description, inverted=cover_description.invert_position + ) + + entities.append( + OverkizNumber( + device.device_url, + data.coordinator, + description, + ) ) - for state in device.definition.states - if (description := SUPPORTED_STATES.get(state)) - ) async_add_entities(entities) diff --git a/homeassistant/components/portainer/coordinator.py b/homeassistant/components/portainer/coordinator.py index ba5357fb58a771..a876fa2a1135a3 100644 --- a/homeassistant/components/portainer/coordinator.py +++ b/homeassistant/components/portainer/coordinator.py @@ -136,7 +136,7 @@ def __init__( async def _async_setup(self) -> None: """Set up the Portainer Data Update Coordinator.""" try: - await self.portainer.get_endpoints() + await self.portainer.portainer_system_status() except PortainerAuthenticationError as err: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, diff --git a/homeassistant/components/profiler/__init__.py b/homeassistant/components/profiler/__init__.py index 39463745a0f37c..1f61fb0a95a5fe 100644 --- a/homeassistant/components/profiler/__init__.py +++ b/homeassistant/components/profiler/__init__.py @@ -1,631 +1,31 @@ """The profiler integration.""" -import asyncio -from collections.abc import Generator -import contextlib -from contextlib import suppress -from datetime import timedelta -from functools import _lru_cache_wrapper -import logging -import reprlib -import sys -import threading -import time -import traceback -from typing import Any, cast - -from lru import LRU -import voluptuous as vol - -from homeassistant.components import persistent_notification from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ENABLED, CONF_SCAN_INTERVAL, CONF_TYPE -from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.service import async_register_admin_service +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN +from .services import LOG_INTERVAL_SUB, async_setup_services -SERVICE_START = "start" -SERVICE_MEMORY = "memory" -SERVICE_START_LOG_OBJECTS = "start_log_objects" -SERVICE_STOP_LOG_OBJECTS = "stop_log_objects" -SERVICE_START_LOG_OBJECT_SOURCES = "start_log_object_sources" -SERVICE_STOP_LOG_OBJECT_SOURCES = "stop_log_object_sources" -SERVICE_DUMP_LOG_OBJECTS = "dump_log_objects" -SERVICE_DUMP_SOCKETS = "dump_sockets" -SERVICE_LRU_STATS = "lru_stats" -SERVICE_LOG_THREAD_FRAMES = "log_thread_frames" -SERVICE_LOG_EVENT_LOOP_SCHEDULED = "log_event_loop_scheduled" -SERVICE_SET_ASYNCIO_DEBUG = "set_asyncio_debug" -SERVICE_LOG_CURRENT_TASKS = "log_current_tasks" - -_LRU_CACHE_WRAPPER_OBJECT = _lru_cache_wrapper.__name__ -_SQLALCHEMY_LRU_OBJECT = "LRUCache" - -_KNOWN_LRU_CLASSES = ( - "EventDataManager", - "EventTypeManager", - "StatesMetaManager", - "StateAttributesManager", - "StatisticsMetaManager", -) - -SERVICES = ( - SERVICE_START, - SERVICE_MEMORY, - SERVICE_START_LOG_OBJECTS, - SERVICE_STOP_LOG_OBJECTS, - SERVICE_DUMP_LOG_OBJECTS, - SERVICE_LRU_STATS, - SERVICE_LOG_THREAD_FRAMES, - SERVICE_LOG_EVENT_LOOP_SCHEDULED, - SERVICE_SET_ASYNCIO_DEBUG, - SERVICE_LOG_CURRENT_TASKS, -) - -DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -DEFAULT_MAX_OBJECTS = 5 -CONF_SECONDS = "seconds" -CONF_MAX_OBJECTS = "max_objects" - -LOG_INTERVAL_SUB = "log_interval_subscription" - - -_LOGGER = logging.getLogger(__name__) +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Profiler.""" + async_setup_services(hass) + return True -async def async_setup_entry( # noqa: C901 - hass: HomeAssistant, entry: ConfigEntry -) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Profiler from a config entry.""" - lock = asyncio.Lock() - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=home-assistant-use-runtime-data - domain_data = hass.data[DOMAIN] = {} - - async def _async_run_profile(call: ServiceCall) -> None: - async with lock: - await _async_generate_profile(hass, call) - - async def _async_run_memory_profile(call: ServiceCall) -> None: - async with lock: - await _async_generate_memory_profile(hass, call) - - async def _async_start_log_objects(call: ServiceCall) -> None: - if LOG_INTERVAL_SUB in domain_data: - raise HomeAssistantError("Object logging already started") - - persistent_notification.async_create( - hass, - ( - "Object growth logging has started. See [the logs](/config/logs) to" - " track the growth of new objects." - ), - title="Object growth logging started", - notification_id="profile_object_logging", - ) - await hass.async_add_executor_job(_log_objects) - domain_data[LOG_INTERVAL_SUB] = async_track_time_interval( - hass, _log_objects, call.data[CONF_SCAN_INTERVAL] - ) - - async def _async_stop_log_objects(call: ServiceCall) -> None: - if LOG_INTERVAL_SUB not in domain_data: - raise HomeAssistantError("Object logging not running") - - persistent_notification.async_dismiss(hass, "profile_object_logging") - domain_data.pop(LOG_INTERVAL_SUB)() - - async def _async_start_object_sources(call: ServiceCall) -> None: - if LOG_INTERVAL_SUB in domain_data: - raise HomeAssistantError("Object logging already started") - - persistent_notification.async_create( - hass, - ( - "Object source logging has started. See [the logs](/config/logs) to" - " track the growth of new objects." - ), - title="Object source logging started", - notification_id="profile_object_source_logging", - ) - - last_ids: set[int] = set() - last_stats: dict[str, int] = {} - - async def _log_object_sources_with_max(*_: Any) -> None: - await hass.async_add_executor_job( - _log_object_sources, call.data[CONF_MAX_OBJECTS], last_ids, last_stats - ) - - await _log_object_sources_with_max() - cancel_track = async_track_time_interval( - hass, _log_object_sources_with_max, call.data[CONF_SCAN_INTERVAL] - ) - - @callback - def _cancel(): - cancel_track() - last_ids.clear() - last_stats.clear() - - domain_data[LOG_INTERVAL_SUB] = _cancel - - @callback - def _async_stop_object_sources(call: ServiceCall) -> None: - if LOG_INTERVAL_SUB not in domain_data: - raise HomeAssistantError("Object logging not running") - - persistent_notification.async_dismiss(hass, "profile_object_source_logging") - domain_data.pop(LOG_INTERVAL_SUB)() - - def _dump_log_objects(call: ServiceCall) -> None: - # Imports deferred to avoid loading modules - # in memory since usually only one part of this - # integration is used at a time - import objgraph # noqa: PLC0415 - - obj_type = call.data[CONF_TYPE] - - for obj in objgraph.by_type(obj_type): - _LOGGER.critical( - "%s object in memory: %s", - obj_type, - _safe_repr(obj), - ) - - persistent_notification.create( - hass, - ( - f"Objects with type {obj_type} have been dumped to the log. See [the" - " logs](/config/logs) to review the repr of the objects." - ), - title="Object dump completed", - notification_id="profile_object_dump", - ) - - def _lru_stats(call: ServiceCall) -> None: - """Log the stats of all lru caches.""" - # Imports deferred to avoid loading modules - # in memory since usually only one part of this - # integration is used at a time - import objgraph # noqa: PLC0415 - - for lru in objgraph.by_type(_LRU_CACHE_WRAPPER_OBJECT): - lru = cast(_lru_cache_wrapper, lru) - _LOGGER.critical( - "Cache stats for lru_cache %s at %s: %s", - lru.__wrapped__, - _get_function_absfile(lru.__wrapped__) or "unknown", - lru.cache_info(), - ) - - for _class in _KNOWN_LRU_CLASSES: - for class_with_lru_attr in objgraph.by_type(_class): - for maybe_lru in class_with_lru_attr.__dict__.values(): - if isinstance(maybe_lru, LRU): - _LOGGER.critical( - "Cache stats for LRU %s at %s: %s", - type(class_with_lru_attr), - _get_function_absfile(class_with_lru_attr) or "unknown", - maybe_lru.get_stats(), - ) - - for lru in objgraph.by_type(_SQLALCHEMY_LRU_OBJECT): - if (data := getattr(lru, "_data", None)) and isinstance(data, dict): - for key, value in dict(data).items(): - _LOGGER.critical( - "Cache data for sqlalchemy LRUCache %s: %s: %s", lru, key, value - ) - - persistent_notification.create( - hass, - ( - "LRU cache states have been dumped to the log. See [the" - " logs](/config/logs) to review the stats." - ), - title="LRU stats completed", - notification_id="profile_lru_stats", - ) - - def _dump_sockets(call: ServiceCall) -> None: - """Dump list of all currently existing sockets to the log.""" - import objgraph # noqa: PLC0415 - - _LOGGER.critical( - "Sockets used by Home Assistant:\n%s", - "\n".join(repr(sock) for sock in objgraph.by_type("socket")), - ) - - async def _async_dump_thread_frames(call: ServiceCall) -> None: - """Log all thread frames.""" - frames = sys._current_frames() # noqa: SLF001 - main_thread = threading.main_thread() - for thread in threading.enumerate(): - if thread == main_thread: - continue - ident = cast(int, thread.ident) - _LOGGER.critical( - "Thread [%s]: %s", - thread.name, - "".join(traceback.format_stack(frames.get(ident))).strip(), - ) - - async def _async_dump_current_tasks(call: ServiceCall) -> None: - """Log all current tasks in the event loop.""" - with _increase_repr_limit(): - for task in asyncio.all_tasks(): - if not task.cancelled(): - _LOGGER.critical("Task: %s", _safe_repr(task)) - - async def _async_dump_scheduled(call: ServiceCall) -> None: - """Log all scheduled in the event loop.""" - with _increase_repr_limit(): - handle: asyncio.Handle - for handle in getattr(hass.loop, "_scheduled"): # noqa: B009 - if not handle.cancelled(): - _LOGGER.critical("Scheduled: %s", handle) - - async def _async_asyncio_debug(call: ServiceCall) -> None: - """Enable or disable asyncio debug.""" - enabled = call.data[CONF_ENABLED] - # Always log this at critical level so we know when - # it's been changed when reviewing logs - _LOGGER.critical("Setting asyncio debug to %s", enabled) - # Make sure the logger is set to at least INFO or - # we won't see the messages - base_logger = logging.getLogger() - if enabled and base_logger.getEffectiveLevel() > logging.INFO: - base_logger.setLevel(logging.INFO) - hass.loop.set_debug(enabled) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - async_register_admin_service( - hass, - DOMAIN, - SERVICE_START, - _async_run_profile, - schema=vol.Schema( - {vol.Optional(CONF_SECONDS, default=60.0): vol.Coerce(float)} - ), - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - async_register_admin_service( - hass, - DOMAIN, - SERVICE_MEMORY, - _async_run_memory_profile, - schema=vol.Schema( - {vol.Optional(CONF_SECONDS, default=60.0): vol.Coerce(float)} - ), - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - async_register_admin_service( - hass, - DOMAIN, - SERVICE_START_LOG_OBJECTS, - _async_start_log_objects, - schema=vol.Schema( - { - vol.Optional( - CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL - ): cv.time_period - } - ), - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - async_register_admin_service( - hass, - DOMAIN, - SERVICE_STOP_LOG_OBJECTS, - _async_stop_log_objects, - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - async_register_admin_service( - hass, - DOMAIN, - SERVICE_START_LOG_OBJECT_SOURCES, - _async_start_object_sources, - schema=vol.Schema( - { - vol.Optional( - CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL - ): cv.time_period, - vol.Optional(CONF_MAX_OBJECTS, default=DEFAULT_MAX_OBJECTS): vol.Range( - min=1, max=1024 - ), - } - ), - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - async_register_admin_service( - hass, - DOMAIN, - SERVICE_STOP_LOG_OBJECT_SOURCES, - _async_stop_object_sources, - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - async_register_admin_service( - hass, - DOMAIN, - SERVICE_DUMP_LOG_OBJECTS, - _dump_log_objects, - schema=vol.Schema({vol.Required(CONF_TYPE): str}), - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - async_register_admin_service( - hass, - DOMAIN, - SERVICE_DUMP_SOCKETS, - _dump_sockets, - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - async_register_admin_service( - hass, - DOMAIN, - SERVICE_LRU_STATS, - _lru_stats, - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - async_register_admin_service( - hass, - DOMAIN, - SERVICE_LOG_THREAD_FRAMES, - _async_dump_thread_frames, - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - async_register_admin_service( - hass, - DOMAIN, - SERVICE_LOG_EVENT_LOOP_SCHEDULED, - _async_dump_scheduled, - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - async_register_admin_service( - hass, - DOMAIN, - SERVICE_SET_ASYNCIO_DEBUG, - _async_asyncio_debug, - schema=vol.Schema({vol.Optional(CONF_ENABLED, default=True): cv.boolean}), - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - async_register_admin_service( - hass, - DOMAIN, - SERVICE_LOG_CURRENT_TASKS, - _async_dump_current_tasks, - ) - return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - for service in SERVICES: - hass.services.async_remove(domain=DOMAIN, service=service) + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data if LOG_INTERVAL_SUB in hass.data[DOMAIN]: hass.data[DOMAIN][LOG_INTERVAL_SUB]() - hass.data.pop(DOMAIN) return True - - -async def _async_generate_profile(hass: HomeAssistant, call: ServiceCall): - # Imports deferred to avoid loading modules - # in memory since usually only one part of this - # integration is used at a time - import cProfile # noqa: PLC0415 - - start_time = int(time.time() * 1000000) - persistent_notification.async_create( - hass, - ( - "The profile has started. This notification will be updated when it is" - " complete." - ), - title="Profile Started", - notification_id=f"profiler_{start_time}", - ) - profiler = cProfile.Profile() - profiler.enable() - await asyncio.sleep(float(call.data[CONF_SECONDS])) - profiler.disable() - - cprofile_path = hass.config.path(f"profile.{start_time}.cprof") - callgrind_path = hass.config.path(f"callgrind.out.{start_time}") - await hass.async_add_executor_job( - _write_profile, profiler, cprofile_path, callgrind_path - ) - persistent_notification.async_create( - hass, - ( - f"Wrote cProfile data to {cprofile_path} and callgrind data to" - f" {callgrind_path}" - ), - title="Profile Complete", - notification_id=f"profiler_{start_time}", - ) - - -async def _async_generate_memory_profile(hass: HomeAssistant, call: ServiceCall): - # Imports deferred to avoid loading modules - # in memory since usually only one part of this - # integration is used at a time - from guppy import hpy # noqa: PLC0415 - - start_time = int(time.time() * 1000000) - persistent_notification.async_create( - hass, - ( - "The memory profile has started. This notification will be updated when it" - " is complete." - ), - title="Profile Started", - notification_id=f"memory_profiler_{start_time}", - ) - heap_profiler = hpy() - heap_profiler.setref() - await asyncio.sleep(float(call.data[CONF_SECONDS])) - heap = heap_profiler.heap() - - heap_path = hass.config.path(f"heap_profile.{start_time}.hpy") - await hass.async_add_executor_job(_write_memory_profile, heap, heap_path) - persistent_notification.async_create( - hass, - f"Wrote heapy memory profile to {heap_path}", - title="Profile Complete", - notification_id=f"memory_profiler_{start_time}", - ) - - -def _write_profile(profiler, cprofile_path, callgrind_path): - # Imports deferred to avoid loading modules - # in memory since usually only one part of this - # integration is used at a time - from pyprof2calltree import convert # noqa: PLC0415 - - profiler.create_stats() - profiler.dump_stats(cprofile_path) - convert(profiler.getstats(), callgrind_path) - - -def _write_memory_profile(heap, heap_path): - heap.byrcs.dump(heap_path) - - -def _log_objects(*_): - # Imports deferred to avoid loading modules - # in memory since usually only one part of this - # integration is used at a time - import objgraph # noqa: PLC0415 - - _LOGGER.critical("Memory Growth: %s", objgraph.growth(limit=1000)) - - -def _get_function_absfile(func: Any) -> str | None: - """Get the absolute file path of a function.""" - import inspect # noqa: PLC0415 - - abs_file: str | None = None - with suppress(Exception): - abs_file = inspect.getabsfile(func) - return abs_file - - -def _safe_repr(obj: Any) -> str: - """Get the repr of an object but keep going if there is an exception. - - We wrap repr to ensure if one object cannot be serialized, we can - still get the rest. - """ - try: - return repr(obj) - except Exception: # noqa: BLE001 - return f"Failed to serialize {type(obj)}" - - -def _find_backrefs_not_to_self(_object: Any) -> list[str]: - import objgraph # noqa: PLC0415 - - return [ - _safe_repr(backref) - for backref in objgraph.find_backref_chain( - _object, lambda obj: obj is not _object - ) - ] - - -def _log_object_sources( - max_objects: int, last_ids: set[int], last_stats: dict[str, int] -) -> None: - # Imports deferred to avoid loading modules - # in memory since usually only one part of this - # integration is used at a time - import gc # noqa: PLC0415 - - gc.collect() - - objects = gc.get_objects() - new_objects: list[object] = [] - new_objects_overflow: dict[str, int] = {} - current_ids = set() - new_stats: dict[str, int] = {} - had_new_object_growth = False - try: - for _object in objects: - object_type = type(_object).__name__ - new_stats[object_type] = new_stats.get(object_type, 0) + 1 - - for _object in objects: - id_ = id(_object) - current_ids.add(id_) - if id_ in last_ids: - continue - object_type = type(_object).__name__ - if last_stats.get(object_type, 0) < new_stats[object_type]: - if len(new_objects) < max_objects: - new_objects.append(_object) - else: - new_objects_overflow.setdefault(object_type, 0) - new_objects_overflow[object_type] += 1 - - for _object in new_objects: - had_new_object_growth = True - object_type = type(_object).__name__ - _LOGGER.critical( - "New object %s (%s/%s) at %s: %s", - object_type, - last_stats.get(object_type, 0), - new_stats[object_type], - _get_function_absfile(_object) or _find_backrefs_not_to_self(_object), - _safe_repr(_object), - ) - - for object_type, count in last_stats.items(): - new_stats[object_type] = max(new_stats.get(object_type, 0), count) - finally: - # Break reference cycles - del objects - del new_objects - last_ids.clear() - last_ids.update(current_ids) - last_stats.clear() - last_stats.update(new_stats) - del new_stats - del current_ids - - if new_objects_overflow: - _LOGGER.critical("New objects overflowed by %s", new_objects_overflow) - elif not had_new_object_growth: - _LOGGER.critical("No new object growth found") - - -@contextlib.contextmanager -def _increase_repr_limit() -> Generator[None]: - """Increase the repr limit.""" - arepr = reprlib.aRepr - original_maxstring = arepr.maxstring - original_maxother = arepr.maxother - arepr.maxstring = 300 - arepr.maxother = 300 - try: - yield - finally: - arepr.maxstring = original_maxstring - arepr.maxother = original_maxother diff --git a/homeassistant/components/profiler/services.py b/homeassistant/components/profiler/services.py new file mode 100644 index 00000000000000..34c217d4f4fd5f --- /dev/null +++ b/homeassistant/components/profiler/services.py @@ -0,0 +1,591 @@ +"""Support for the profiler services.""" + +import asyncio +from collections.abc import Generator +import contextlib +from contextlib import suppress +from datetime import timedelta +from functools import _lru_cache_wrapper +import logging +import reprlib +import sys +import threading +import time +import traceback +from typing import Any, cast + +from lru import LRU +import voluptuous as vol + +from homeassistant.components import persistent_notification +from homeassistant.const import CONF_ENABLED, CONF_SCAN_INTERVAL, CONF_TYPE +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.service import async_register_admin_service + +from .const import DOMAIN + +SERVICE_START = "start" +SERVICE_MEMORY = "memory" +SERVICE_START_LOG_OBJECTS = "start_log_objects" +SERVICE_STOP_LOG_OBJECTS = "stop_log_objects" +SERVICE_START_LOG_OBJECT_SOURCES = "start_log_object_sources" +SERVICE_STOP_LOG_OBJECT_SOURCES = "stop_log_object_sources" +SERVICE_DUMP_LOG_OBJECTS = "dump_log_objects" +SERVICE_DUMP_SOCKETS = "dump_sockets" +SERVICE_LRU_STATS = "lru_stats" +SERVICE_LOG_THREAD_FRAMES = "log_thread_frames" +SERVICE_LOG_EVENT_LOOP_SCHEDULED = "log_event_loop_scheduled" +SERVICE_SET_ASYNCIO_DEBUG = "set_asyncio_debug" +SERVICE_LOG_CURRENT_TASKS = "log_current_tasks" + +_LRU_CACHE_WRAPPER_OBJECT = _lru_cache_wrapper.__name__ +_SQLALCHEMY_LRU_OBJECT = "LRUCache" + +_KNOWN_LRU_CLASSES = ( + "EventDataManager", + "EventTypeManager", + "StatesMetaManager", + "StateAttributesManager", + "StatisticsMetaManager", +) + +DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) + +DEFAULT_MAX_OBJECTS = 5 + +CONF_SECONDS = "seconds" +CONF_MAX_OBJECTS = "max_objects" + +LOG_INTERVAL_SUB = "log_interval_subscription" + + +_LOGGER = logging.getLogger(__name__) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 + """Register the profiler services.""" + lock = asyncio.Lock() + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data + domain_data = hass.data[DOMAIN] = {} + + async def _async_run_profile(call: ServiceCall) -> None: + async with lock: + await _async_generate_profile(hass, call) + + async def _async_run_memory_profile(call: ServiceCall) -> None: + async with lock: + await _async_generate_memory_profile(hass, call) + + async def _async_start_log_objects(call: ServiceCall) -> None: + if LOG_INTERVAL_SUB in domain_data: + raise HomeAssistantError("Object logging already started") + + persistent_notification.async_create( + hass, + ( + "Object growth logging has started. See [the logs](/config/logs) to" + " track the growth of new objects." + ), + title="Object growth logging started", + notification_id="profile_object_logging", + ) + await hass.async_add_executor_job(_log_objects) + domain_data[LOG_INTERVAL_SUB] = async_track_time_interval( + hass, _log_objects, call.data[CONF_SCAN_INTERVAL] + ) + + async def _async_stop_log_objects(call: ServiceCall) -> None: + if LOG_INTERVAL_SUB not in domain_data: + raise HomeAssistantError("Object logging not running") + + persistent_notification.async_dismiss(hass, "profile_object_logging") + domain_data.pop(LOG_INTERVAL_SUB)() + + async def _async_start_object_sources(call: ServiceCall) -> None: + if LOG_INTERVAL_SUB in domain_data: + raise HomeAssistantError("Object logging already started") + + persistent_notification.async_create( + hass, + ( + "Object source logging has started. See [the logs](/config/logs) to" + " track the growth of new objects." + ), + title="Object source logging started", + notification_id="profile_object_source_logging", + ) + + last_ids: set[int] = set() + last_stats: dict[str, int] = {} + + async def _log_object_sources_with_max(*_: Any) -> None: + await hass.async_add_executor_job( + _log_object_sources, call.data[CONF_MAX_OBJECTS], last_ids, last_stats + ) + + await _log_object_sources_with_max() + cancel_track = async_track_time_interval( + hass, _log_object_sources_with_max, call.data[CONF_SCAN_INTERVAL] + ) + + @callback + def _cancel(): + cancel_track() + last_ids.clear() + last_stats.clear() + + domain_data[LOG_INTERVAL_SUB] = _cancel + + @callback + def _async_stop_object_sources(call: ServiceCall) -> None: + if LOG_INTERVAL_SUB not in domain_data: + raise HomeAssistantError("Object logging not running") + + persistent_notification.async_dismiss(hass, "profile_object_source_logging") + domain_data.pop(LOG_INTERVAL_SUB)() + + def _dump_log_objects(call: ServiceCall) -> None: + # Imports deferred to avoid loading modules + # in memory since usually only one part of this + # integration is used at a time + import objgraph # noqa: PLC0415 + + obj_type = call.data[CONF_TYPE] + + for obj in objgraph.by_type(obj_type): + _LOGGER.critical( + "%s object in memory: %s", + obj_type, + _safe_repr(obj), + ) + + persistent_notification.create( + hass, + ( + f"Objects with type {obj_type} have been dumped to the log. See [the" + " logs](/config/logs) to review the repr of the objects." + ), + title="Object dump completed", + notification_id="profile_object_dump", + ) + + def _lru_stats(call: ServiceCall) -> None: + """Log the stats of all lru caches.""" + # Imports deferred to avoid loading modules + # in memory since usually only one part of this + # integration is used at a time + import objgraph # noqa: PLC0415 + + for lru in objgraph.by_type(_LRU_CACHE_WRAPPER_OBJECT): + lru = cast(_lru_cache_wrapper, lru) + _LOGGER.critical( + "Cache stats for lru_cache %s at %s: %s", + lru.__wrapped__, + _get_function_absfile(lru.__wrapped__) or "unknown", + lru.cache_info(), + ) + + for _class in _KNOWN_LRU_CLASSES: + for class_with_lru_attr in objgraph.by_type(_class): + for maybe_lru in class_with_lru_attr.__dict__.values(): + if isinstance(maybe_lru, LRU): + _LOGGER.critical( + "Cache stats for LRU %s at %s: %s", + type(class_with_lru_attr), + _get_function_absfile(class_with_lru_attr) or "unknown", + maybe_lru.get_stats(), + ) + + for lru in objgraph.by_type(_SQLALCHEMY_LRU_OBJECT): + if (data := getattr(lru, "_data", None)) and isinstance(data, dict): + for key, value in dict(data).items(): + _LOGGER.critical( + "Cache data for sqlalchemy LRUCache %s: %s: %s", lru, key, value + ) + + persistent_notification.create( + hass, + ( + "LRU cache states have been dumped to the log. See [the" + " logs](/config/logs) to review the stats." + ), + title="LRU stats completed", + notification_id="profile_lru_stats", + ) + + def _dump_sockets(call: ServiceCall) -> None: + """Dump list of all currently existing sockets to the log.""" + import objgraph # noqa: PLC0415 + + _LOGGER.critical( + "Sockets used by Home Assistant:\n%s", + "\n".join(repr(sock) for sock in objgraph.by_type("socket")), + ) + + async def _async_dump_thread_frames(call: ServiceCall) -> None: + """Log all thread frames.""" + frames = sys._current_frames() # noqa: SLF001 + main_thread = threading.main_thread() + for thread in threading.enumerate(): + if thread == main_thread: + continue + ident = cast(int, thread.ident) + _LOGGER.critical( + "Thread [%s]: %s", + thread.name, + "".join(traceback.format_stack(frames.get(ident))).strip(), + ) + + async def _async_dump_current_tasks(call: ServiceCall) -> None: + """Log all current tasks in the event loop.""" + with _increase_repr_limit(): + for task in asyncio.all_tasks(): + if not task.cancelled(): + _LOGGER.critical("Task: %s", _safe_repr(task)) + + async def _async_dump_scheduled(call: ServiceCall) -> None: + """Log all scheduled in the event loop.""" + with _increase_repr_limit(): + handle: asyncio.Handle + for handle in getattr(hass.loop, "_scheduled"): # noqa: B009 + if not handle.cancelled(): + _LOGGER.critical("Scheduled: %s", handle) + + async def _async_asyncio_debug(call: ServiceCall) -> None: + """Enable or disable asyncio debug.""" + enabled = call.data[CONF_ENABLED] + # Always log this at critical level so we know when + # it's been changed when reviewing logs + _LOGGER.critical("Setting asyncio debug to %s", enabled) + # Make sure the logger is set to at least INFO or + # we won't see the messages + base_logger = logging.getLogger() + if enabled and base_logger.getEffectiveLevel() > logging.INFO: + base_logger.setLevel(logging.INFO) + hass.loop.set_debug(enabled) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_START, + _async_run_profile, + schema=vol.Schema( + {vol.Optional(CONF_SECONDS, default=60.0): vol.Coerce(float)} + ), + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_MEMORY, + _async_run_memory_profile, + schema=vol.Schema( + {vol.Optional(CONF_SECONDS, default=60.0): vol.Coerce(float)} + ), + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_START_LOG_OBJECTS, + _async_start_log_objects, + schema=vol.Schema( + { + vol.Optional( + CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL + ): cv.time_period + } + ), + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_STOP_LOG_OBJECTS, + _async_stop_log_objects, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_START_LOG_OBJECT_SOURCES, + _async_start_object_sources, + schema=vol.Schema( + { + vol.Optional( + CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL + ): cv.time_period, + vol.Optional(CONF_MAX_OBJECTS, default=DEFAULT_MAX_OBJECTS): vol.Range( + min=1, max=1024 + ), + } + ), + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_STOP_LOG_OBJECT_SOURCES, + _async_stop_object_sources, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_DUMP_LOG_OBJECTS, + _dump_log_objects, + schema=vol.Schema({vol.Required(CONF_TYPE): str}), + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_DUMP_SOCKETS, + _dump_sockets, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_LRU_STATS, + _lru_stats, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_LOG_THREAD_FRAMES, + _async_dump_thread_frames, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_LOG_EVENT_LOOP_SCHEDULED, + _async_dump_scheduled, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_SET_ASYNCIO_DEBUG, + _async_asyncio_debug, + schema=vol.Schema({vol.Optional(CONF_ENABLED, default=True): cv.boolean}), + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_LOG_CURRENT_TASKS, + _async_dump_current_tasks, + ) + + +async def _async_generate_profile(hass: HomeAssistant, call: ServiceCall): + # Imports deferred to avoid loading modules + # in memory since usually only one part of this + # integration is used at a time + import cProfile # noqa: PLC0415 + + start_time = int(time.time() * 1000000) + persistent_notification.async_create( + hass, + ( + "The profile has started. This notification will be updated when it is" + " complete." + ), + title="Profile Started", + notification_id=f"profiler_{start_time}", + ) + profiler = cProfile.Profile() + profiler.enable() + await asyncio.sleep(float(call.data[CONF_SECONDS])) + profiler.disable() + + cprofile_path = hass.config.path(f"profile.{start_time}.cprof") + callgrind_path = hass.config.path(f"callgrind.out.{start_time}") + await hass.async_add_executor_job( + _write_profile, profiler, cprofile_path, callgrind_path + ) + persistent_notification.async_create( + hass, + ( + f"Wrote cProfile data to {cprofile_path} and callgrind data to" + f" {callgrind_path}" + ), + title="Profile Complete", + notification_id=f"profiler_{start_time}", + ) + + +async def _async_generate_memory_profile(hass: HomeAssistant, call: ServiceCall): + # Imports deferred to avoid loading modules + # in memory since usually only one part of this + # integration is used at a time + from guppy import hpy # noqa: PLC0415 + + start_time = int(time.time() * 1000000) + persistent_notification.async_create( + hass, + ( + "The memory profile has started. This notification will be updated when it" + " is complete." + ), + title="Profile Started", + notification_id=f"memory_profiler_{start_time}", + ) + heap_profiler = hpy() + heap_profiler.setref() + await asyncio.sleep(float(call.data[CONF_SECONDS])) + heap = heap_profiler.heap() + + heap_path = hass.config.path(f"heap_profile.{start_time}.hpy") + await hass.async_add_executor_job(_write_memory_profile, heap, heap_path) + persistent_notification.async_create( + hass, + f"Wrote heapy memory profile to {heap_path}", + title="Profile Complete", + notification_id=f"memory_profiler_{start_time}", + ) + + +def _write_profile(profiler, cprofile_path, callgrind_path): + # Imports deferred to avoid loading modules + # in memory since usually only one part of this + # integration is used at a time + from pyprof2calltree import convert # noqa: PLC0415 + + profiler.create_stats() + profiler.dump_stats(cprofile_path) + convert(profiler.getstats(), callgrind_path) + + +def _write_memory_profile(heap, heap_path): + heap.byrcs.dump(heap_path) + + +def _log_objects(*_): + # Imports deferred to avoid loading modules + # in memory since usually only one part of this + # integration is used at a time + import objgraph # noqa: PLC0415 + + _LOGGER.critical("Memory Growth: %s", objgraph.growth(limit=1000)) + + +def _get_function_absfile(func: Any) -> str | None: + """Get the absolute file path of a function.""" + import inspect # noqa: PLC0415 + + abs_file: str | None = None + with suppress(Exception): + abs_file = inspect.getabsfile(func) + return abs_file + + +def _safe_repr(obj: Any) -> str: + """Get the repr of an object but keep going if there is an exception. + + We wrap repr to ensure if one object cannot be serialized, we can + still get the rest. + """ + try: + return repr(obj) + except Exception: # noqa: BLE001 + return f"Failed to serialize {type(obj)}" + + +def _find_backrefs_not_to_self(_object: Any) -> list[str]: + import objgraph # noqa: PLC0415 + + return [ + _safe_repr(backref) + for backref in objgraph.find_backref_chain( + _object, lambda obj: obj is not _object + ) + ] + + +def _log_object_sources( + max_objects: int, last_ids: set[int], last_stats: dict[str, int] +) -> None: + # Imports deferred to avoid loading modules + # in memory since usually only one part of this + # integration is used at a time + import gc # noqa: PLC0415 + + gc.collect() + + objects = gc.get_objects() + new_objects: list[object] = [] + new_objects_overflow: dict[str, int] = {} + current_ids = set() + new_stats: dict[str, int] = {} + had_new_object_growth = False + try: + for _object in objects: + object_type = type(_object).__name__ + new_stats[object_type] = new_stats.get(object_type, 0) + 1 + + for _object in objects: + id_ = id(_object) + current_ids.add(id_) + if id_ in last_ids: + continue + object_type = type(_object).__name__ + if last_stats.get(object_type, 0) < new_stats[object_type]: + if len(new_objects) < max_objects: + new_objects.append(_object) + else: + new_objects_overflow.setdefault(object_type, 0) + new_objects_overflow[object_type] += 1 + + for _object in new_objects: + had_new_object_growth = True + object_type = type(_object).__name__ + _LOGGER.critical( + "New object %s (%s/%s) at %s: %s", + object_type, + last_stats.get(object_type, 0), + new_stats[object_type], + _get_function_absfile(_object) or _find_backrefs_not_to_self(_object), + _safe_repr(_object), + ) + + for object_type, count in last_stats.items(): + new_stats[object_type] = max(new_stats.get(object_type, 0), count) + finally: + # Break reference cycles + del objects + del new_objects + last_ids.clear() + last_ids.update(current_ids) + last_stats.clear() + last_stats.update(new_stats) + del new_stats + del current_ids + + if new_objects_overflow: + _LOGGER.critical("New objects overflowed by %s", new_objects_overflow) + elif not had_new_object_growth: + _LOGGER.critical("No new object growth found") + + +@contextlib.contextmanager +def _increase_repr_limit() -> Generator[None]: + """Increase the repr limit.""" + arepr = reprlib.aRepr + original_maxstring = arepr.maxstring + original_maxother = arepr.maxother + arepr.maxstring = 300 + arepr.maxother = 300 + try: + yield + finally: + arepr.maxstring = original_maxstring + arepr.maxother = original_maxother diff --git a/homeassistant/components/rachio/__init__.py b/homeassistant/components/rachio/__init__.py index ab0886096cc76c..90de274fb9382d 100644 --- a/homeassistant/components/rachio/__init__.py +++ b/homeassistant/components/rachio/__init__.py @@ -10,9 +10,12 @@ from homeassistant.const import CONF_API_KEY, CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType -from .const import CONF_CLOUDHOOK_URL, CONF_MANUAL_RUN_MINS +from .const import CONF_CLOUDHOOK_URL, CONF_MANUAL_RUN_MINS, DOMAIN from .device import RachioConfigEntry, RachioPerson +from .services import async_setup_services from .webhooks import ( async_get_or_create_registered_webhook_id_and_url, async_register_webhook, @@ -23,6 +26,14 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.CALENDAR, Platform.SWITCH] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Rachio integration.""" + async_setup_services(hass) + return True + async def async_unload_entry(hass: HomeAssistant, entry: RachioConfigEntry) -> bool: """Unload a config entry.""" diff --git a/homeassistant/components/rachio/device.py b/homeassistant/components/rachio/device.py index 919f323029aab0..5bf10c08f23ffa 100644 --- a/homeassistant/components/rachio/device.py +++ b/homeassistant/components/rachio/device.py @@ -5,16 +5,13 @@ from typing import Any, override from rachiopy import Rachio -import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv from .const import ( - DOMAIN, KEY_BASE_STATIONS, KEY_DEVICES, KEY_ENABLED, @@ -30,31 +27,14 @@ KEY_USERNAME, KEY_ZONES, LISTEN_EVENT_TYPES, - MODEL_GENERATION_1, - SERVICE_PAUSE_WATERING, - SERVICE_RESUME_WATERING, - SERVICE_STOP_WATERING, WEBHOOK_CONST_ID, ) from .coordinator import RachioScheduleUpdateCoordinator, RachioUpdateCoordinator _LOGGER = logging.getLogger(__name__) -ATTR_DEVICES = "devices" -ATTR_DURATION = "duration" PERMISSION_ERROR = "7" -PAUSE_SERVICE_SCHEMA = vol.Schema( - { - vol.Optional(ATTR_DEVICES): cv.string, - vol.Optional(ATTR_DURATION, default=60): cv.positive_int, - } -) - -RESUME_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_DEVICES): cv.string}) - -STOP_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_DEVICES): cv.string}) - type RachioConfigEntry = ConfigEntry[RachioPerson] @@ -72,66 +52,8 @@ def __init__(self, rachio: Rachio, config_entry: RachioConfigEntry) -> None: self._base_stations: list[RachioBaseStation] = [] async def async_setup(self, hass: HomeAssistant) -> None: - """Create rachio devices and services.""" + """Create rachio devices.""" await hass.async_add_executor_job(self._setup, hass) - can_pause = False - for rachio_iro in self._controllers: - # Generation 1 controllers don't support pause or resume - if rachio_iro.model.split("_")[0] != MODEL_GENERATION_1: - can_pause = True - break - - all_controllers = [rachio_iro.name for rachio_iro in self._controllers] - - def pause_water(service: ServiceCall) -> None: - """Service to pause watering on all or specific controllers.""" - duration = service.data[ATTR_DURATION] - devices = service.data.get(ATTR_DEVICES, all_controllers) - for iro in self._controllers: - if iro.name in devices: - iro.pause_watering(duration) - - def resume_water(service: ServiceCall) -> None: - """Service to resume watering on all or specific controllers.""" - devices = service.data.get(ATTR_DEVICES, all_controllers) - for iro in self._controllers: - if iro.name in devices: - iro.resume_watering() - - def stop_water(service: ServiceCall) -> None: - """Service to stop watering on all or specific controllers.""" - devices = service.data.get(ATTR_DEVICES, all_controllers) - for iro in self._controllers: - if iro.name in devices: - iro.stop_watering() - - # If only hose timers on account, none of these services apply - if not all_controllers: - return - - hass.services.async_register( - DOMAIN, - SERVICE_STOP_WATERING, - stop_water, - schema=STOP_SERVICE_SCHEMA, - ) - - if not can_pause: - return - - hass.services.async_register( - DOMAIN, - SERVICE_PAUSE_WATERING, - pause_water, - schema=PAUSE_SERVICE_SCHEMA, - ) - - hass.services.async_register( - DOMAIN, - SERVICE_RESUME_WATERING, - resume_water, - schema=RESUME_SERVICE_SCHEMA, - ) def _setup(self, hass: HomeAssistant) -> None: """Rachio device setup.""" diff --git a/homeassistant/components/rachio/services.py b/homeassistant/components/rachio/services.py new file mode 100644 index 00000000000000..1e30e85e4645dc --- /dev/null +++ b/homeassistant/components/rachio/services.py @@ -0,0 +1,135 @@ +"""Services for the Rachio integration.""" + +import logging + +import voluptuous as vol + +from homeassistant.const import ATTR_ENTITY_ID, ATTR_ID, Platform +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import ( + config_validation as cv, + entity_registry as er, + service, +) + +from .const import ( + DOMAIN, + KEY_ID, + MODEL_GENERATION_1, + SERVICE_PAUSE_WATERING, + SERVICE_RESUME_WATERING, + SERVICE_START_MULTIPLE_ZONES, + SERVICE_STOP_WATERING, +) +from .device import RachioConfigEntry + +_LOGGER = logging.getLogger(__name__) + +ATTR_DEVICES = "devices" +ATTR_DURATION = "duration" +ATTR_SORT_ORDER = "sortOrder" + +PAUSE_SERVICE_SCHEMA = vol.Schema( + { + vol.Optional(ATTR_DEVICES): cv.string, + vol.Optional(ATTR_DURATION, default=60): cv.positive_int, + } +) + +RESUME_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_DEVICES): cv.string}) + +START_MULTIPLE_ZONES_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_DURATION): cv.ensure_list_csv, + } +) + +STOP_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_DEVICES): cv.string}) + + +def _stop_water(call: ServiceCall) -> None: + """Stop watering on all or specific controllers.""" + entry: RachioConfigEntry = service.async_get_config_entry(call.hass, DOMAIN, None) + person = entry.runtime_data + devices = call.data.get(ATTR_DEVICES, [iro.name for iro in person.controllers]) + for iro in person.controllers: + if iro.name in devices: + iro.stop_watering() + + +def _pause_water(call: ServiceCall) -> None: + """Pause watering on all or specific controllers.""" + entry: RachioConfigEntry = service.async_get_config_entry(call.hass, DOMAIN, None) + person = entry.runtime_data + devices = call.data.get(ATTR_DEVICES, [iro.name for iro in person.controllers]) + for iro in person.controllers: + if iro.name in devices and iro.model.split("_")[0] != MODEL_GENERATION_1: + iro.pause_watering(call.data[ATTR_DURATION]) + + +def _resume_water(call: ServiceCall) -> None: + """Resume watering on all or specific controllers.""" + entry: RachioConfigEntry = service.async_get_config_entry(call.hass, DOMAIN, None) + person = entry.runtime_data + devices = call.data.get(ATTR_DEVICES, [iro.name for iro in person.controllers]) + for iro in person.controllers: + if iro.name in devices and iro.model.split("_")[0] != MODEL_GENERATION_1: + iro.resume_watering() + + +def _start_multiple(call: ServiceCall) -> None: + """Start multiple zones in sequence.""" + entry: RachioConfigEntry = service.async_get_config_entry(call.hass, DOMAIN, None) + person = entry.runtime_data + entity_reg = er.async_get(call.hass) + duration = iter(call.data[ATTR_DURATION]) + default_time = call.data[ATTR_DURATION][0] + + entity_to_zone_id = { + entity_reg.async_get_entity_id( + Platform.SWITCH, + DOMAIN, + f"{controller.controller_id}-zone-{zone[KEY_ID]}", + ): zone[KEY_ID] + for controller in person.controllers + for zone in controller.list_zones() + } + + zones_list = [ + { + ATTR_ID: entity_to_zone_id[entity_id], + ATTR_DURATION: int(next(duration, default_time)) * 60, + ATTR_SORT_ORDER: count, + } + for count, entity_id in enumerate(call.data[ATTR_ENTITY_ID]) + if entity_id in entity_to_zone_id + ] + + if not zones_list: + raise HomeAssistantError("No matching zones found in given entity_ids") + + person.start_multiple_zones(zones_list) + _LOGGER.debug("Starting zone(s) %s", call.data[ATTR_ENTITY_ID]) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register Rachio services.""" + + hass.services.async_register( + DOMAIN, SERVICE_STOP_WATERING, _stop_water, schema=STOP_SERVICE_SCHEMA + ) + hass.services.async_register( + DOMAIN, SERVICE_PAUSE_WATERING, _pause_water, schema=PAUSE_SERVICE_SCHEMA + ) + hass.services.async_register( + DOMAIN, SERVICE_RESUME_WATERING, _resume_water, schema=RESUME_SERVICE_SCHEMA + ) + hass.services.async_register( + DOMAIN, + SERVICE_START_MULTIPLE_ZONES, + _start_multiple, + schema=START_MULTIPLE_ZONES_SCHEMA, + ) diff --git a/homeassistant/components/rachio/switch.py b/homeassistant/components/rachio/switch.py index 146618fad2be60..83025aa03c85c0 100644 --- a/homeassistant/components/rachio/switch.py +++ b/homeassistant/components/rachio/switch.py @@ -9,9 +9,7 @@ import voluptuous as vol from homeassistant.components.switch import SwitchEntity -from homeassistant.const import ATTR_ENTITY_ID, ATTR_ID -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity @@ -22,7 +20,6 @@ from .const import ( CONF_MANUAL_RUN_MINS, DEFAULT_MANUAL_RUN_MINS, - DOMAIN, KEY_CURRENT_STATUS, KEY_CUSTOM_CROP, KEY_CUSTOM_SHADE, @@ -45,7 +42,6 @@ SCHEDULE_TYPE_FIXED, SCHEDULE_TYPE_FLEX, SERVICE_SET_ZONE_MOISTURE, - SERVICE_START_MULTIPLE_ZONES, SERVICE_START_WATERING, SIGNAL_RACHIO_CONTROLLER_UPDATE, SIGNAL_RACHIO_RAIN_DELAY_UPDATE, @@ -80,7 +76,6 @@ ATTR_SCHEDULE_ENABLED = "Enabled" ATTR_SCHEDULE_DURATION = "Duration" ATTR_SCHEDULE_TYPE = "Type" -ATTR_SORT_ORDER = "sortOrder" ATTR_WATERING_DURATION = "Watering Duration seconds" ATTR_ZONE_NUMBER = "Zone number" ATTR_ZONE_SHADE = "Shade" @@ -88,13 +83,6 @@ ATTR_ZONE_SUMMARY = "Summary" ATTR_ZONE_TYPE = "Type" -START_MULTIPLE_ZONES_SCHEMA = vol.Schema( - { - vol.Required(ATTR_ENTITY_ID): cv.entity_ids, - vol.Required(ATTR_DURATION): cv.ensure_list_csv, - } -) - async def async_setup_entry( hass: HomeAssistant, @@ -102,47 +90,14 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Rachio switches.""" - zone_entities = [] has_flex_sched = False entities = await hass.async_add_executor_job(_create_entities, hass, config_entry) for entity in entities: - if isinstance(entity, RachioZone): - zone_entities.append(entity) if isinstance(entity, RachioSchedule) and entity.type == SCHEDULE_TYPE_FLEX: has_flex_sched = True async_add_entities(entities) - def start_multiple(service: ServiceCall) -> None: - """Service to start multiple zones in sequence.""" - zones_list = [] - person = config_entry.runtime_data - entity_id = service.data[ATTR_ENTITY_ID] - duration = iter(service.data[ATTR_DURATION]) - default_time = service.data[ATTR_DURATION][0] - entity_to_zone_id = { - entity.entity_id: entity.zone_id for entity in zone_entities - } - - for count, data in enumerate(entity_id): - if data in entity_to_zone_id: - # Time can be passed as a list per zone, - # or one time for all zones - time = int(next(duration, default_time)) * 60 - zones_list.append( - { - ATTR_ID: entity_to_zone_id.get(data), - ATTR_DURATION: time, - ATTR_SORT_ORDER: count, - } - ) - - if len(zones_list) != 0: - person.start_multiple_zones(zones_list) - _LOGGER.debug("Starting zone(s) %s", entity_id) - else: - raise HomeAssistantError("No matching zones found in given entity_ids") - platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( SERVICE_START_WATERING, @@ -152,18 +107,6 @@ def start_multiple(service: ServiceCall) -> None: "turn_on", ) - # If only hose timers on account, none of these services apply - if not zone_entities: - return - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_START_MULTIPLE_ZONES, - start_multiple, - schema=START_MULTIPLE_ZONES_SCHEMA, - ) - if has_flex_sched: platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index c1b7eaf54fdee8..a2527f5d8307ea 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -10,14 +10,9 @@ from homeassistant import config_entries from homeassistant.components import http, websocket_api from homeassistant.components.http.data_validator import RequestDataValidator -from homeassistant.const import ATTR_NAME, Platform -from homeassistant.core import ( - DOMAIN as HOMEASSISTANT_DOMAIN, - HomeAssistant, - ServiceCall, - callback, -) -from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.const import Platform +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.typing import ConfigType from .common import ( @@ -26,19 +21,8 @@ ShoppingListConfigEntry, _get_shopping_data, ) -from .const import ( - ATTR_REVERSE, - DEFAULT_REVERSE, - DOMAIN, - SERVICE_ADD_ITEM, - SERVICE_CLEAR_COMPLETED_ITEMS, - SERVICE_COMPLETE_ALL, - SERVICE_COMPLETE_ITEM, - SERVICE_INCOMPLETE_ALL, - SERVICE_INCOMPLETE_ITEM, - SERVICE_REMOVE_ITEM, - SERVICE_SORT, -) +from .const import DOMAIN +from .services import async_register_services PLATFORMS = [Platform.TODO] @@ -46,15 +30,10 @@ CONFIG_SCHEMA = vol.Schema({DOMAIN: {}}, extra=vol.ALLOW_EXTRA) -SERVICE_ITEM_SCHEMA = vol.Schema({vol.Required(ATTR_NAME): cv.string}) -SERVICE_LIST_SCHEMA = vol.Schema({}) -SERVICE_SORT_SCHEMA = vol.Schema( - {vol.Optional(ATTR_REVERSE, default=DEFAULT_REVERSE): bool} -) - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Initialize the shopping list.""" + async_register_services(hass) if DOMAIN not in config: return True @@ -88,113 +67,9 @@ async def async_setup_entry( hass: HomeAssistant, config_entry: ShoppingListConfigEntry ) -> bool: """Set up shopping list from config flow.""" - - async def add_item_service(call: ServiceCall) -> None: - """Add an item with `name`.""" - await config_entry.runtime_data.async_add(call.data[ATTR_NAME]) - - async def remove_item_service(call: ServiceCall) -> None: - """Remove the first item with matching `name`.""" - data = config_entry.runtime_data - name = call.data[ATTR_NAME] - - try: - item = [item for item in data.items if item["name"] == name][0] - # pylint: disable-next=home-assistant-action-swallowed-exception - except IndexError: - _LOGGER.error("Removing of item failed: %s cannot be found", name) - else: - await data.async_remove(str(item["id"])) - - async def complete_item_service(call: ServiceCall) -> None: - """Mark the first item with matching `name` as completed.""" - name = call.data[ATTR_NAME] - try: - await config_entry.runtime_data.async_complete(name) - # pylint: disable-next=home-assistant-action-swallowed-exception - except NoMatchingShoppingListItem: - _LOGGER.error("Completing of item failed: %s cannot be found", name) - - async def incomplete_item_service(call: ServiceCall) -> None: - """Mark the first item with matching `name` as incomplete.""" - data = config_entry.runtime_data - name = call.data[ATTR_NAME] - - try: - item = [item for item in data.items if item["name"] == name][0] - # pylint: disable-next=home-assistant-action-swallowed-exception - except IndexError: - _LOGGER.error("Restoring of item failed: %s cannot be found", name) - else: - await data.async_update(str(item["id"]), {"name": name, "complete": False}) - - async def complete_all_service(call: ServiceCall) -> None: - """Mark all items in the list as complete.""" - await data.async_update_list({"complete": True}) - - async def incomplete_all_service(call: ServiceCall) -> None: - """Mark all items in the list as incomplete.""" - await data.async_update_list({"complete": False}) - - async def clear_completed_items_service(call: ServiceCall) -> None: - """Clear all completed items from the list.""" - await data.async_clear_completed() - - async def sort_list_service(call: ServiceCall) -> None: - """Sort all items by name.""" - await data.async_sort(call.data[ATTR_REVERSE]) - data = config_entry.runtime_data = ShoppingData(hass) await data.async_load() - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, SERVICE_ADD_ITEM, add_item_service, schema=SERVICE_ITEM_SCHEMA - ) - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, SERVICE_REMOVE_ITEM, remove_item_service, schema=SERVICE_ITEM_SCHEMA - ) - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, SERVICE_COMPLETE_ITEM, complete_item_service, schema=SERVICE_ITEM_SCHEMA - ) - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_INCOMPLETE_ITEM, - incomplete_item_service, - schema=SERVICE_ITEM_SCHEMA, - ) - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_COMPLETE_ALL, - complete_all_service, - schema=SERVICE_LIST_SCHEMA, - ) - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_INCOMPLETE_ALL, - incomplete_all_service, - schema=SERVICE_LIST_SCHEMA, - ) - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_CLEAR_COMPLETED_ITEMS, - clear_completed_items_service, - schema=SERVICE_LIST_SCHEMA, - ) - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_SORT, - sort_list_service, - schema=SERVICE_SORT_SCHEMA, - ) - hass.http.register_view(ShoppingListView) hass.http.register_view(CreateShoppingListItemView) hass.http.register_view(UpdateShoppingListItemView) diff --git a/homeassistant/components/shopping_list/common.py b/homeassistant/components/shopping_list/common.py index 4a8523c39e32c5..4305ba84bdedee 100644 --- a/homeassistant/components/shopping_list/common.py +++ b/homeassistant/components/shopping_list/common.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_NAME from homeassistant.core import Context, HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import service from homeassistant.helpers.json import save_json from homeassistant.util.json import JsonValueType, load_json_array @@ -271,9 +271,5 @@ def _async_notify(self) -> None: def _get_shopping_data(hass: HomeAssistant) -> ShoppingData: - entries: list[ShoppingListConfigEntry] = hass.config_entries.async_loaded_entries( - DOMAIN - ) - if not entries: - raise HomeAssistantError("No shopping list config entry found") - return entries[0].runtime_data + entry: ShoppingListConfigEntry = service.async_get_config_entry(hass, DOMAIN, None) + return entry.runtime_data diff --git a/homeassistant/components/shopping_list/services.py b/homeassistant/components/shopping_list/services.py new file mode 100644 index 00000000000000..693c0d15b0be96 --- /dev/null +++ b/homeassistant/components/shopping_list/services.py @@ -0,0 +1,132 @@ +"""Support for shopping list services.""" + +import logging + +import voluptuous as vol + +from homeassistant.const import ATTR_NAME +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import config_validation as cv + +from .common import NoMatchingShoppingListItem, _get_shopping_data +from .const import ( + ATTR_REVERSE, + DEFAULT_REVERSE, + DOMAIN, + SERVICE_ADD_ITEM, + SERVICE_CLEAR_COMPLETED_ITEMS, + SERVICE_COMPLETE_ALL, + SERVICE_COMPLETE_ITEM, + SERVICE_INCOMPLETE_ALL, + SERVICE_INCOMPLETE_ITEM, + SERVICE_REMOVE_ITEM, + SERVICE_SORT, +) + +_LOGGER = logging.getLogger(__name__) + +SERVICE_ITEM_SCHEMA = vol.Schema({vol.Required(ATTR_NAME): cv.string}) +SERVICE_LIST_SCHEMA = vol.Schema({}) +SERVICE_SORT_SCHEMA = vol.Schema( + {vol.Optional(ATTR_REVERSE, default=DEFAULT_REVERSE): bool} +) + + +@callback +def async_register_services(hass: HomeAssistant) -> None: + """Register shopping list services.""" + + async def add_item_service(call: ServiceCall) -> None: + """Add an item with `name`.""" + await _get_shopping_data(hass).async_add(call.data[ATTR_NAME]) + + async def remove_item_service(call: ServiceCall) -> None: + """Remove the first item with matching `name`.""" + data = _get_shopping_data(hass) + name = call.data[ATTR_NAME] + + try: + item = [item for item in data.items if item["name"] == name][0] + # pylint: disable-next=home-assistant-action-swallowed-exception + except IndexError: + _LOGGER.error("Removing of item failed: %s cannot be found", name) + else: + await data.async_remove(str(item["id"])) + + async def complete_item_service(call: ServiceCall) -> None: + """Mark the first item with matching `name` as completed.""" + name = call.data[ATTR_NAME] + try: + await _get_shopping_data(hass).async_complete(name) + # pylint: disable-next=home-assistant-action-swallowed-exception + except NoMatchingShoppingListItem: + _LOGGER.error("Completing of item failed: %s cannot be found", name) + + async def incomplete_item_service(call: ServiceCall) -> None: + """Mark the first item with matching `name` as incomplete.""" + data = _get_shopping_data(hass) + name = call.data[ATTR_NAME] + + try: + item = [item for item in data.items if item["name"] == name][0] + # pylint: disable-next=home-assistant-action-swallowed-exception + except IndexError: + _LOGGER.error("Restoring of item failed: %s cannot be found", name) + else: + await data.async_update(str(item["id"]), {"name": name, "complete": False}) + + async def complete_all_service(call: ServiceCall) -> None: + """Mark all items in the list as complete.""" + await _get_shopping_data(hass).async_update_list({"complete": True}) + + async def incomplete_all_service(call: ServiceCall) -> None: + """Mark all items in the list as incomplete.""" + await _get_shopping_data(hass).async_update_list({"complete": False}) + + async def clear_completed_items_service(call: ServiceCall) -> None: + """Clear all completed items from the list.""" + await _get_shopping_data(hass).async_clear_completed() + + async def sort_list_service(call: ServiceCall) -> None: + """Sort all items by name.""" + await _get_shopping_data(hass).async_sort(call.data[ATTR_REVERSE]) + + hass.services.async_register( + DOMAIN, SERVICE_ADD_ITEM, add_item_service, schema=SERVICE_ITEM_SCHEMA + ) + hass.services.async_register( + DOMAIN, SERVICE_REMOVE_ITEM, remove_item_service, schema=SERVICE_ITEM_SCHEMA + ) + hass.services.async_register( + DOMAIN, SERVICE_COMPLETE_ITEM, complete_item_service, schema=SERVICE_ITEM_SCHEMA + ) + hass.services.async_register( + DOMAIN, + SERVICE_INCOMPLETE_ITEM, + incomplete_item_service, + schema=SERVICE_ITEM_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_COMPLETE_ALL, + complete_all_service, + schema=SERVICE_LIST_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_INCOMPLETE_ALL, + incomplete_all_service, + schema=SERVICE_LIST_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_CLEAR_COMPLETED_ITEMS, + clear_completed_items_service, + schema=SERVICE_LIST_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_SORT, + sort_list_service, + schema=SERVICE_SORT_SCHEMA, + ) diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 27d3d3eadc0ba2..21fb5a88b54469 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -15,7 +15,7 @@ from requests.exceptions import HTTPError, Timeout from soco import events_asyncio, zonegroupstate import soco.config as soco_config -from soco.core import SoCo +from soco.core import SoCo, soco_reset from soco.events_base import Event as SonosEvent, SubscriptionBase from soco.exceptions import SoCoException import voluptuous as vol @@ -114,6 +114,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: SonosConfigEntry) -> bool: """Set up Sonos from a config entry.""" + _LOGGER.debug("Setting up Sonos config entry: %s", entry.entry_id) + soco_reset() soco_config.EVENTS_MODULE = events_asyncio soco_config.REQUEST_TIMEOUT = 9.5 soco_config.ZGT_EVENT_FALLBACK = False @@ -153,6 +155,8 @@ async def async_unload_entry( config_entry, PLATFORMS ) await hass.data[DATA_SONOS_DISCOVERY_MANAGER].async_shutdown() + soco_reset() + _LOGGER.debug("Sonos config entry unloaded: %s", config_entry.entry_id) return unload_ok diff --git a/homeassistant/components/steam_online/__init__.py b/homeassistant/components/steam_online/__init__.py index 8d4464e06c25cd..335636df105c03 100644 --- a/homeassistant/components/steam_online/__init__.py +++ b/homeassistant/components/steam_online/__init__.py @@ -1,9 +1,13 @@ """The Steam integration.""" +from typing import TYPE_CHECKING + +from homeassistant.config_entries import ConfigSubentry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er +from .const import CONF_ACCOUNTS, DOMAIN, SUBENTRY_TYPE_FRIEND from .coordinator import SteamConfigEntry, SteamDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] @@ -16,9 +20,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: SteamConfigEntry) -> boo entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + return True +async def _async_update_listener(hass: HomeAssistant, entry: SteamConfigEntry) -> None: + """Handle update.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_unload_entry(hass: HomeAssistant, entry: SteamConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) @@ -40,4 +51,24 @@ def migrate_unique_id(entity_entry: er.RegistryEntry) -> dict[str, str] | None: await er.async_migrate_entries(hass, entry.entry_id, migrate_unique_id) hass.config_entries.async_update_entry(entry, version=2) + if entry.version < 3: + for steamid, name in entry.options[CONF_ACCOUNTS].items(): + if steamid == entry.unique_id: + continue + subentry = ConfigSubentry( + subentry_type=SUBENTRY_TYPE_FRIEND, + title=name, + unique_id=steamid, + data={}, # type: ignore[arg-type] + ) + hass.config_entries.async_add_subentry(entry, subentry) + + dev_reg = dr.async_get(hass) + if device := dev_reg.async_get_device({(DOMAIN, entry.entry_id)}): + if TYPE_CHECKING: + assert entry.unique_id + dev_reg.async_update_device( + device.id, new_identifiers={(DOMAIN, entry.unique_id)} + ) + hass.config_entries.async_update_entry(entry, version=3, options={}) return True diff --git a/homeassistant/components/steam_online/config_flow.py b/homeassistant/components/steam_online/config_flow.py index fc5313376e801c..b70f56b6b4f608 100644 --- a/homeassistant/components/steam_online/config_flow.py +++ b/homeassistant/components/steam_online/config_flow.py @@ -1,8 +1,9 @@ """Config flow for Steam integration.""" -from collections.abc import Iterator, Mapping +from collections.abc import Mapping +from itertools import batched import logging -from typing import Any, override +from typing import TYPE_CHECKING, Any, override import steam.api import voluptuous as vol @@ -10,15 +11,21 @@ from homeassistant.config_entries import ( SOURCE_REAUTH, SOURCE_RECONFIGURE, + ConfigEntryState, ConfigFlow, ConfigFlowResult, - OptionsFlowWithReload, + ConfigSubentryFlow, + SubentryFlowResult, ) -from homeassistant.const import CONF_API_KEY, CONF_NAME, Platform +from homeassistant.const import CONF_API_KEY, CONF_NAME from homeassistant.core import callback -from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, +) -from .const import CONF_ACCOUNT, CONF_ACCOUNTS, DOMAIN, PLACEHOLDERS +from .const import CONF_ACCOUNT, DOMAIN, PLACEHOLDERS, SUBENTRY_TYPE_FRIEND from .coordinator import SteamConfigEntry _LOGGER = logging.getLogger(__name__) @@ -47,16 +54,16 @@ def validate_input(user_input: dict[str, str]) -> dict[str, str | int]: class SteamFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow for Steam.""" - VERSION = 2 + VERSION = 3 - @staticmethod + @classmethod @callback @override - def async_get_options_flow( - config_entry: SteamConfigEntry, - ) -> SteamOptionsFlowHandler: - """Get the options flow for this handler.""" - return SteamOptionsFlowHandler(config_entry) + def async_get_supported_subentry_types( + cls, config_entry: SteamConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this integration.""" + return {SUBENTRY_TYPE_FRIEND: FriendSubentryFlowHandler} @override async def async_step_user( @@ -67,6 +74,14 @@ async def async_step_user( if user_input is not None: await self.async_set_unique_id(user_input[CONF_ACCOUNT]) self._abort_if_unique_id_configured() + + config_entries = self.hass.config_entries.async_entries(DOMAIN) + for entry in config_entries: + if user_input[CONF_ACCOUNT] in { + subentry.unique_id for subentry in entry.subentries.values() + }: + return self.async_abort(reason="already_configured_as_subentry") + try: res = await self.hass.async_add_executor_job(validate_input, user_input) if res is not None: @@ -83,11 +98,7 @@ async def async_step_user( _LOGGER.exception("Unknown exception") errors["base"] = "unknown" if not errors: - return self.async_create_entry( - title=name, - data=user_input, - options={CONF_ACCOUNTS: {user_input[CONF_ACCOUNT]: name}}, - ) + return self.async_create_entry(title=name, data=user_input) user_input = user_input or {} return self.async_show_form( step_id="user", @@ -138,9 +149,7 @@ async def async_step_reauth_confirm( errors["base"] = "unknown" if not errors: - return self.async_update_reload_and_abort( - entry, data_updates=user_input - ) + return self.async_update_and_abort(entry, data_updates=user_input) return self.async_show_form( step_id=( "reauth_confirm" if self.source == SOURCE_REAUTH else SOURCE_RECONFIGURE @@ -153,77 +162,119 @@ async def async_step_reauth_confirm( ) -def _batch_ids(ids: list[str]) -> Iterator[list[str]]: - for i in range(0, len(ids), MAX_IDS_TO_REQUEST): - yield ids[i : i + MAX_IDS_TO_REQUEST] +class FriendSubentryFlowHandler(ConfigSubentryFlow): + """Handle subentry flow for adding a friend.""" + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Subentry user flow.""" + errors: dict[str, str] = {} + config_entry: SteamConfigEntry = self._get_entry() -class SteamOptionsFlowHandler(OptionsFlowWithReload): - """Handle Steam client options.""" + if config_entry.state is not ConfigEntryState.LOADED: + return self.async_abort(reason="config_entry_not_loaded") - def __init__(self, entry: SteamConfigEntry) -> None: - """Initialize options flow.""" - self.options = dict(entry.options) + client = config_entry.runtime_data.user_interface + if TYPE_CHECKING: + assert config_entry.unique_id - async def async_step_init( - self, user_input: dict[str, dict[str, str]] | None = None - ) -> ConfigFlowResult: - """Manage Steam options.""" if user_input is not None: - for _id in self.options[CONF_ACCOUNTS]: - if _id not in user_input[CONF_ACCOUNTS] and ( - entity_id := er.async_get(self.hass).async_get_entity_id( - Platform.SENSOR, DOMAIN, f"{_id}_account" - ) - ): - er.async_get(self.hass).async_remove(entity_id) - channel_data = { - CONF_ACCOUNTS: { - _id: name - for _id, name in self.options[CONF_ACCOUNTS].items() - if _id in user_input[CONF_ACCOUNTS] - } - } - return self.async_create_entry(title="", data=channel_data) - error = None - try: - users = { - name["steamid"]: name["personaname"] - for name in await self.hass.async_add_executor_job(self.get_accounts) - } - if not users: - error = {"base": "unauthorized"} + config_entries = self.hass.config_entries.async_entries(DOMAIN) + if user_input[CONF_ACCOUNT] in { + entry.unique_id for entry in config_entries + }: + return self.async_abort(reason="already_configured_as_entry") + for entry in config_entries: + if user_input[CONF_ACCOUNT] in { + subentry.unique_id + for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_FRIEND) + }: + return self.async_abort(reason="already_configured") + + try: + title = await self.hass.async_add_executor_job( + lambda: client.GetPlayerSummaries( + steamids=[user_input[CONF_ACCOUNT]] + )["response"]["players"]["player"][0]["personaname"] + ) + except steam.api.HTTPTimeoutError: + errors["base"] = "timeout_connect" + except steam.api.HTTPError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unknown exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=title, + data={}, + unique_id=user_input[CONF_ACCOUNT], + ) + def get_accounts() -> list[dict[str, Any]]: + friends = client.GetFriendList(steamid=config_entry.unique_id)[ + "friendslist" + ]["friends"] + accounts = [] + for steamids in batched( + [friend["steamid"] for friend in friends], + MAX_IDS_TO_REQUEST, + strict=False, + ): + accounts.extend( + client.GetPlayerSummaries(steamids=list(steamids))["response"][ + "players" + ]["player"] + ) + return accounts + + try: + accounts = await self.hass.async_add_executor_job(get_accounts) except steam.api.HTTPTimeoutError: - users = self.options[CONF_ACCOUNTS] + return self.async_abort(reason="timeout_connect") + except steam.api.HTTPError as e: + if "401" in str(e): + me = config_entry.runtime_data.data[config_entry.unique_id] + return self.async_abort( + reason="friendlist_private", + description_placeholders={ + CONF_NAME: me.personaname, + "privacy_settings_url": f"{me.profileurl}edit/settings", + }, + ) + return self.async_abort(reason="cannot_connect") + except Exception: + _LOGGER.exception("Unknown exception") + return self.async_abort(reason="unknown") - options = { - vol.Required( - CONF_ACCOUNTS, - default=set(self.options[CONF_ACCOUNTS]), - ): cv.multi_select(users | self.options[CONF_ACCOUNTS]), + existing_subentries = { + subentry.unique_id + for subentry in config_entry.get_subentries_of_type(SUBENTRY_TYPE_FRIEND) } - self.options[CONF_ACCOUNTS] = users | self.options[CONF_ACCOUNTS] + options = [ + SelectOptionDict( + value=account["steamid"], + label=account["personaname"], + ) + for account in accounts + if account["steamid"] not in existing_subentries + ] + + if not options: + return self.async_abort(reason="no_more_friends") return self.async_show_form( - step_id="init", data_schema=vol.Schema(options), errors=error + step_id="user", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_ACCOUNT): SelectSelector( + SelectSelectorConfig(options=options, sort=True) + ) + } + ), + user_input, + ), + errors=errors, ) - - def get_accounts(self) -> list[dict[str, str | int]]: - """Get accounts.""" - interface = steam.api.interface("ISteamUser") - try: - friends = interface.GetFriendList( - steamid=self.config_entry.data[CONF_ACCOUNT] - ) - _users_str = [user["steamid"] for user in friends["friendslist"]["friends"]] - except steam.api.HTTPError: - return [] - names = [] - for id_batch in _batch_ids(_users_str): - names.extend( - interface.GetPlayerSummaries(steamids=id_batch)["response"]["players"][ - "player" - ] - ) - return names diff --git a/homeassistant/components/steam_online/const.py b/homeassistant/components/steam_online/const.py index 14654da3b765f6..89d305419b8495 100644 --- a/homeassistant/components/steam_online/const.py +++ b/homeassistant/components/steam_online/const.py @@ -35,3 +35,5 @@ STEAM_HEADER_IMAGE_FILE = "header.jpg" STEAM_MAIN_IMAGE_FILE = "capsule_616x353.jpg" STEAM_ICON_URL = "https://steamcdn-a.akamaihd.net/steamcommunity/public/images/apps/" + +SUBENTRY_TYPE_FRIEND = "friend" diff --git a/homeassistant/components/steam_online/coordinator.py b/homeassistant/components/steam_online/coordinator.py index 7c3df2b82c4a8e..ea2f37f11f5225 100644 --- a/homeassistant/components/steam_online/coordinator.py +++ b/homeassistant/components/steam_online/coordinator.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from datetime import timedelta import logging -from typing import override +from typing import TYPE_CHECKING, override import steam.api @@ -13,7 +13,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_ACCOUNTS, DOMAIN +from .const import DOMAIN, SUBENTRY_TYPE_FRIEND type SteamConfigEntry = ConfigEntry[SteamDataUpdateCoordinator] @@ -76,8 +76,16 @@ async def _async_setup(self) -> None: def _update(self) -> dict[str, PlayerData]: """Fetch data from API endpoint.""" - accounts = self.config_entry.options[CONF_ACCOUNTS] - _ids = list(accounts) + if TYPE_CHECKING: + assert self.config_entry.unique_id + _ids = [self.config_entry.unique_id] + _ids.extend( + subentry.unique_id + for subentry in self.config_entry.get_subentries_of_type( + SUBENTRY_TYPE_FRIEND + ) + if subentry.unique_id + ) response = self.user_interface.GetPlayerSummaries(steamids=_ids) players = { diff --git a/homeassistant/components/steam_online/entity.py b/homeassistant/components/steam_online/entity.py index be8eebde909b26..ac62ce76ca4598 100644 --- a/homeassistant/components/steam_online/entity.py +++ b/homeassistant/components/steam_online/entity.py @@ -25,9 +25,9 @@ def __init__( self.entity_description = description self._attr_unique_id = f"{steamid}_{description.key}" self._attr_device_info = DeviceInfo( - configuration_url="https://store.steampowered.com", + configuration_url=str(coordinator.data[steamid].profileurl), entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + identifiers={(DOMAIN, steamid)}, manufacturer=DEFAULT_NAME, - name=DEFAULT_NAME, + name=str(coordinator.data[steamid].personaname), ) diff --git a/homeassistant/components/steam_online/sensor.py b/homeassistant/components/steam_online/sensor.py index 7749c3a5b29b04..58190afb580d41 100644 --- a/homeassistant/components/steam_online/sensor.py +++ b/homeassistant/components/steam_online/sensor.py @@ -13,14 +13,14 @@ from homeassistant.util import dt as dt_util from .const import ( - CONF_ACCOUNTS, STEAM_API_URL, STEAM_HEADER_IMAGE_FILE, STEAM_ICON_URL, STEAM_MAIN_IMAGE_FILE, STEAM_STATUSES, + SUBENTRY_TYPE_FRIEND, ) -from .coordinator import PlayerData, SteamConfigEntry, SteamDataUpdateCoordinator +from .coordinator import PlayerData, SteamConfigEntry from .entity import SteamEntity PARALLEL_UPDATES = 1 @@ -37,7 +37,6 @@ class SteamSensorEntityDescription(SensorEntityDescription): """Steam sensor description.""" value_fn: Callable[[PlayerData], StateType] - name_fn: Callable[[PlayerData], str] entity_picture_fn: Callable[[PlayerData], str] | None = None @@ -46,8 +45,8 @@ class SteamSensorEntityDescription(SensorEntityDescription): key=SteamSensor.ACCOUNT, translation_key=SteamSensor.ACCOUNT, value_fn=lambda x: STEAM_STATUSES[x.personastate], - name_fn=lambda x: x.personaname, entity_picture_fn=lambda x: x.avatarfull, + name=None, ), ) @@ -61,28 +60,28 @@ async def async_setup_entry( coordinator = entry.runtime_data async_add_entities( - SteamSensorEntity(coordinator, steamid, description) - for steamid in entry.options[CONF_ACCOUNTS] + SteamSensorEntity(coordinator, entry.unique_id, description) for description in SENSOR_DESCRIPTIONS - if steamid in coordinator.data + if entry.unique_id is not None and entry.unique_id in coordinator.data ) + for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_FRIEND): + async_add_entities( + [ + SteamSensorEntity(coordinator, subentry.unique_id, description) + for description in SENSOR_DESCRIPTIONS + if subentry.unique_id is not None + and subentry.unique_id in coordinator.data + ], + config_subentry_id=subentry.subentry_id, + ) + class SteamSensorEntity(SteamEntity, SensorEntity): """Representation of a Steam sensor entity.""" entity_description: SteamSensorEntityDescription - def __init__( - self, - coordinator: SteamDataUpdateCoordinator, - steamid: str, - description: SteamSensorEntityDescription, - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator, steamid, description) - self._attr_name = self.entity_description.name_fn(coordinator.data[steamid]) - @property @override def native_value(self) -> StateType: diff --git a/homeassistant/components/steam_online/strings.json b/homeassistant/components/steam_online/strings.json index 4d51800a505e4a..1d9289147bab14 100644 --- a/homeassistant/components/steam_online/strings.json +++ b/homeassistant/components/steam_online/strings.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "already_configured_as_subentry": "This Steam account is already configured as a sub-entry.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, @@ -46,6 +47,41 @@ } } }, + "config_subentries": { + "friend": { + "abort": { + "already_configured": "Already configured as a friend in this or another account.", + "already_configured_as_entry": "This account is already configured as a service and cannot be added as a friend.", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "config_entry_not_loaded": "Cannot add friend accounts when the main account is disabled or not loaded.", + "friendlist_private": "Your friend list appears to be private or inaccessible.\n\nTo add friend accounts open Steam and go to [**{name} > Profile > Edit Profile > Privacy Settings**]({privacy_settings_url}) and set **Friends List** to **Public**.\n\nOnce your friends are added, you can switch it back to your preferred privacy setting.", + "no_more_friends": "All friends from your friend list have already been added.", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "entry_type": "Friend", + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "initiate_flow": { + "user": "Add friend" + }, + "step": { + "user": { + "data": { + "account": "Friend" + }, + "data_description": { + "account": "Select a friend from your friend list to track their online status." + }, + "description": "Track the online status of a Steam friend.", + "title": "Friend online status" + } + } + } + }, "entity": { "sensor": { "account": { @@ -80,17 +116,5 @@ "timeout_exception": { "message": "Failed to connect to Steam due to a request timeout" } - }, - "options": { - "error": { - "unauthorized": "Friends list restricted: Please refer to the documentation on how to see all other friends" - }, - "step": { - "init": { - "data": { - "accounts": "Names of accounts to be monitored" - } - } - } } } diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index a38b0335165ed0..81fe526a293832 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -187,10 +187,8 @@ async def make_device_data( devices_data.vacuums.append((device, coordinator)) if isinstance(device, Device) and device.device_type in [ - "Smart Lock", "Smart Lock Lite", "Smart Lock Pro", - "Smart Lock Ultra", "Smart Lock Vision", "Smart Lock Vision Pro", "Smart Lock Pro Wifi", diff --git a/homeassistant/components/switchbot_cloud/const.py b/homeassistant/components/switchbot_cloud/const.py index 61de4193ac5488..953f303cc7f5c6 100644 --- a/homeassistant/components/switchbot_cloud/const.py +++ b/homeassistant/components/switchbot_cloud/const.py @@ -124,6 +124,12 @@ class SwitchbotCloudDeviceConfig: "WoIOSensor": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)), "Hub 2": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)), "MeterPro": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)), + "Smart Lock": SwitchbotCloudDeviceConfig( + True, entity_config=(Platform.BINARY_SENSOR, Platform.SENSOR, Platform.LOCK) + ), + "Smart Lock Ultra": SwitchbotCloudDeviceConfig( + True, entity_config=(Platform.SENSOR, Platform.BINARY_SENSOR, Platform.LOCK) + ), "MeterPro(CO2)": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)), "AI Art Frame": SwitchbotCloudDeviceConfig( True, entity_config=(Platform.SENSOR, Platform.BUTTON, Platform.IMAGE) diff --git a/homeassistant/components/switchbot_cloud/lock.py b/homeassistant/components/switchbot_cloud/lock.py index bb06518111b7f2..e2e93f0518cc33 100644 --- a/homeassistant/components/switchbot_cloud/lock.py +++ b/homeassistant/components/switchbot_cloud/lock.py @@ -44,7 +44,7 @@ def __init__( def _set_attributes(self) -> None: """Set attributes from coordinator data.""" if coord_data := self.coordinator.data: - self._attr_is_locked = coord_data["lockState"] == "locked" + self._attr_is_locked = coord_data["lockState"].lower() == "locked" if self.__model != "Smart Lock Lite": self._attr_supported_features = LockEntityFeature.OPEN diff --git a/homeassistant/components/tankerkoenig/__init__.py b/homeassistant/components/tankerkoenig/__init__.py index 46387ef34f61de..4574f2bc0b4558 100644 --- a/homeassistant/components/tankerkoenig/__init__.py +++ b/homeassistant/components/tankerkoenig/__init__.py @@ -3,7 +3,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import DEFAULT_SCAN_INTERVAL from .coordinator import TankerkoenigConfigEntry, TankerkoenigDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -13,8 +13,6 @@ async def async_setup_entry( hass: HomeAssistant, entry: TankerkoenigConfigEntry ) -> bool: """Set a tankerkoenig configuration entry up.""" - hass.data.setdefault(DOMAIN, {}) - coordinator = TankerkoenigDataUpdateCoordinator(hass, entry, DEFAULT_SCAN_INTERVAL) await coordinator.async_setup() await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 040887e6713298..a61b9cb5e60e0f 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -1,6 +1,7 @@ """Support for covers which integrate with other components.""" -from typing import TYPE_CHECKING, Any, override +from dataclasses import asdict, dataclass +from typing import TYPE_CHECKING, Any, Self, override import voluptuous as vol @@ -22,6 +23,7 @@ AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) +from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator, validators as template_validators @@ -158,13 +160,40 @@ def async_create_preview_cover( ) -class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity): +@dataclass(kw_only=True) +class CoverExtraStoredData(ExtraStoredData): + """Holds extra stored data for template cover entities.""" + + current_cover_position: int | None + current_cover_tilt_position: int | None + is_opening: bool | None + is_closing: bool | None + + @override + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the cover data.""" + return asdict(self) + + @classmethod + def from_dict(cls, restored: dict[str, Any]) -> Self: + """Initialize a stored cover state from a dict.""" + return cls( + current_cover_position=restored["current_cover_position"], + current_cover_tilt_position=restored["current_cover_tilt_position"], + is_opening=restored["is_opening"], + is_closing=restored["is_closing"], + ) + + +class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity, RestoreEntity): """Representation of a template cover features.""" _entity_id_format = ENTITY_ID_FORMAT _optimistic_entity = True _extra_optimistic_options = (CONF_POSITION,) _state_option = CONF_STATE + _restore_state_extra_data = CoverExtraStoredData + _restore_state_properties = ("_attr_current_cover_position",) # The super init is not called because TemplateEntity # and TriggerEntity will call @@ -324,6 +353,25 @@ async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: if self._tilt_optimistic: self.async_write_ha_state() + @property + @override + def extra_restore_state_data(self) -> CoverExtraStoredData: + """Return cover specific state data to be restored.""" + return CoverExtraStoredData( + current_cover_position=self._attr_current_cover_position, + current_cover_tilt_position=self._attr_current_cover_tilt_position, + is_opening=self._attr_is_opening, + is_closing=self._attr_is_closing, + ) + + @override + def restore_extra_data(self, extra_data: CoverExtraStoredData) -> None: + """Restore the extra data.""" + self._attr_current_cover_position = extra_data.current_cover_position + self._attr_current_cover_tilt_position = extra_data.current_cover_tilt_position + self._attr_is_opening = extra_data.is_opening + self._attr_is_closing = extra_data.is_closing + class StateCoverEntity(TemplateEntity, AbstractTemplateCover): """Representation of a Template cover.""" diff --git a/homeassistant/components/template/device_tracker.py b/homeassistant/components/template/device_tracker.py index 3c7f013ddbc79a..e0103fdf92caf0 100644 --- a/homeassistant/components/template/device_tracker.py +++ b/homeassistant/components/template/device_tracker.py @@ -1,7 +1,8 @@ """Support for device trackers which integrates with other components.""" from collections.abc import Callable -from typing import Any +from dataclasses import asdict, dataclass +from typing import Any, Self, override import voluptuous as vol @@ -19,6 +20,7 @@ AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) +from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator, validators as template_validators @@ -174,10 +176,37 @@ def async_create_preview_tracker( ) -class AbstractTemplateTracker(AbstractTemplateEntity, TrackerEntity): +@dataclass(kw_only=True) +class TrackerExtraStoredData(ExtraStoredData): + """Holds extra stored data for template tracker entities.""" + + in_zones: list[str] | None + latitude: float | None + longitude: float | None + location_accuracy: float + + @override + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the tracker data.""" + return asdict(self) + + @classmethod + def from_dict(cls, restored: dict[str, Any]) -> Self: + """Initialize a stored tracker state from a dict.""" + return cls( + in_zones=restored["in_zones"], + latitude=restored["latitude"], + longitude=restored["longitude"], + location_accuracy=restored["location_accuracy"], + ) + + +class AbstractTemplateTracker(AbstractTemplateEntity, TrackerEntity, RestoreEntity): """Representation of a template device tracker features.""" _entity_id_format = ENTITY_ID_FORMAT + _restore_state_extra_data = TrackerExtraStoredData + _restore_state_properties = ("_attr_in_zones",) # The super init is not called because TemplateEntity # and TriggerEntity will call @@ -217,6 +246,25 @@ def _update_location_accuracy(self, value: float | None) -> None: """Update the location accuracy.""" self._attr_location_accuracy = self._location_accuracy_validator(value) or 0.0 + @property + @override + def extra_restore_state_data(self) -> TrackerExtraStoredData: + """Return tracker specific state data to be restored.""" + return TrackerExtraStoredData( + in_zones=self._attr_in_zones, + latitude=self._attr_latitude, + longitude=self._attr_longitude, + location_accuracy=self._attr_location_accuracy, + ) + + @override + def restore_extra_data(self, extra_data: TrackerExtraStoredData) -> None: + """Restore the extra data.""" + self._attr_in_zones = extra_data.in_zones + self._attr_latitude = extra_data.latitude + self._attr_longitude = extra_data.longitude + self._attr_location_accuracy = extra_data.location_accuracy + class StateTrackerEntity(TemplateEntity, AbstractTemplateTracker): """Representation of a Template device tracker.""" diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index d2c7b3b68150fc..5a8f221ae98a07 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -1,8 +1,9 @@ """Support for Template fans.""" +from dataclasses import asdict, dataclass from enum import StrEnum import logging -from typing import TYPE_CHECKING, Any, override +from typing import TYPE_CHECKING, Any, Self, override import voluptuous as vol @@ -22,6 +23,7 @@ AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) +from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import validators as template_validators @@ -155,12 +157,56 @@ def async_create_preview_fan( ) -class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): +@dataclass(kw_only=True) +class FanExtraStoredData(ExtraStoredData): + """Fan extra stored data.""" + + is_on: bool | None + percentage: int | None + preset_mode: str | None + oscillating: bool | None + direction: str | None + + @override + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the fan data.""" + return asdict(self) + + @classmethod + def from_dict(cls, restored: dict[str, Any]) -> Self | None: + """Initialize a stored fan data from a dict.""" + is_on = restored.get("is_on") + percentage = restored.get("percentage") + preset_mode = restored.get("preset_mode") + oscillating = restored.get("oscillating") + direction = restored.get("direction") + if is_on is not None and not isinstance(is_on, bool): + return None + if percentage is not None and not isinstance(percentage, int): + return None + if preset_mode is not None and not isinstance(preset_mode, str): + return None + if oscillating is not None and not isinstance(oscillating, bool): + return None + if direction is not None and not isinstance(direction, str): + return None + return cls( + is_on=is_on, + percentage=percentage, + preset_mode=preset_mode, + oscillating=oscillating, + direction=direction, + ) + + +class AbstractTemplateFan(AbstractTemplateEntity, FanEntity, RestoreEntity): """Representation of a template fan features.""" _entity_id_format = ENTITY_ID_FORMAT _optimistic_entity = True _state_option = CONF_STATE + _restore_state_extra_data = FanExtraStoredData + _restore_state_properties = ("_attr_is_on",) # The super init is not called because TemplateEntity # and TriggerEntity will call @@ -344,6 +390,27 @@ async def async_set_direction(self, direction: str) -> None: ", ".join(_VALID_DIRECTIONS), ) + @property + @override + def extra_restore_state_data(self) -> FanExtraStoredData: + """Return extra state data to be restored.""" + return FanExtraStoredData( + is_on=self._attr_is_on, + percentage=self._attr_percentage, + preset_mode=self._attr_preset_mode, + oscillating=self._attr_oscillating, + direction=self._attr_current_direction, + ) + + @override + def restore_extra_data(self, extra_data: FanExtraStoredData) -> None: + """Restore extra state data.""" + self._attr_is_on = extra_data.is_on + self._attr_percentage = extra_data.percentage + self._attr_preset_mode = extra_data.preset_mode + self._attr_oscillating = extra_data.oscillating + self._attr_current_direction = extra_data.direction + class StateFanEntity(TemplateEntity, AbstractTemplateFan): """A template fan component.""" diff --git a/homeassistant/components/tesla_wall_connector/const.py b/homeassistant/components/tesla_wall_connector/const.py index 2a660ee1aae99f..9a9d31e6f09f48 100644 --- a/homeassistant/components/tesla_wall_connector/const.py +++ b/homeassistant/components/tesla_wall_connector/const.py @@ -8,4 +8,8 @@ WALLCONNECTOR_DATA_VITALS = "vitals" WALLCONNECTOR_DATA_LIFETIME = "lifetime" -WALLCONNECTOR_DEVICE_NAME = "Tesla Wall Connector" +WALLCONNECTOR_DEVICE_MANUFACTURER = "Tesla" +WALLCONNECTOR_DEVICE_MODEL = "Wall Connector" +WALLCONNECTOR_DEVICE_NAME = ( + f"{WALLCONNECTOR_DEVICE_MANUFACTURER} {WALLCONNECTOR_DEVICE_MODEL}" +) diff --git a/homeassistant/components/tesla_wall_connector/entity.py b/homeassistant/components/tesla_wall_connector/entity.py index da412aeeeac904..5254654274cb26 100644 --- a/homeassistant/components/tesla_wall_connector/entity.py +++ b/homeassistant/components/tesla_wall_connector/entity.py @@ -7,7 +7,12 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, WALLCONNECTOR_DEVICE_NAME +from .const import ( + DOMAIN, + WALLCONNECTOR_DEVICE_MANUFACTURER, + WALLCONNECTOR_DEVICE_MODEL, + WALLCONNECTOR_DEVICE_NAME, +) from .coordinator import WallConnectorCoordinator, WallConnectorData @@ -43,7 +48,9 @@ def device_info(self) -> DeviceInfo: return DeviceInfo( identifiers={(DOMAIN, self.wall_connector_data.serial_number)}, name=WALLCONNECTOR_DEVICE_NAME, - model=self.wall_connector_data.part_number, + manufacturer=WALLCONNECTOR_DEVICE_MANUFACTURER, + model=WALLCONNECTOR_DEVICE_MODEL, + model_id=self.wall_connector_data.part_number, + serial_number=self.wall_connector_data.serial_number, sw_version=self.wall_connector_data.firmware_version, - manufacturer="Tesla", ) diff --git a/homeassistant/components/tesla_wall_connector/manifest.json b/homeassistant/components/tesla_wall_connector/manifest.json index 10d32279cde06b..223f6e904d6a32 100644 --- a/homeassistant/components/tesla_wall_connector/manifest.json +++ b/homeassistant/components/tesla_wall_connector/manifest.json @@ -1,7 +1,7 @@ { "domain": "tesla_wall_connector", "name": "Tesla Wall Connector", - "codeowners": ["@einarhauks"], + "codeowners": ["@einarhauks", "@sarabveer"], "config_flow": true, "dhcp": [ { diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index 26669ce64bf974..4f8a8a06c2958d 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -10,6 +10,7 @@ from tesla_fleet_api.exceptions import ( Forbidden, InvalidToken, + LoginRequired, SubscriptionRequired, TeslaFleetError, ) @@ -265,6 +266,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - translation_domain=DOMAIN, translation_key="auth_failed_invalid_token", ) from e + except LoginRequired as e: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_failed_login_required", + ) from e except SubscriptionRequired as e: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, @@ -402,6 +408,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - translation_domain=DOMAIN, translation_key="auth_failed_invalid_token", ) from e + except LoginRequired as e: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_failed_login_required", + ) from e except SubscriptionRequired as e: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index e7f9f6dff25f40..84957296f254d7 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -1131,6 +1131,9 @@ "auth_failed_invalid_token": { "message": "Access token is invalid, please reauthenticate" }, + "auth_failed_login_required": { + "message": "Login is no longer valid, please reauthenticate" + }, "auth_failed_migration": { "message": "Failed to migrate to OAuth, please reauthenticate" }, diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index 577b47fd186724..cdc7f7ed891ea3 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -245,18 +245,16 @@ def _async_process_public_event( Only the start of an event is dispatched, routed to the subscribers that registered for this device and event type; an entity that cares about a - sub-type (e.g. a smart-detect object type) filters further itself. The - device is resolved by ``device_id`` (the stable cross-API join key), not - the public ``device_mac``, so the key comes from the same store the - entities derive ``self.device.mac`` from and matches without assuming - both mac strings are byte-identical. + sub-type (e.g. a smart-detect object type) filters further itself. + Subscriptions are keyed by ``device_id`` (the stable cross-API join key, + shared by the private and public bootstraps), so the event routes + directly without a bootstrap lookup. """ if change is not EventChange.STARTED: return - device = self.api.bootstrap.get_device_from_id(event.device_id) - if device is None or not ( + if not ( subscriptions := self._public_event_subscriptions.get( - (device.mac, event.type) + (event.device_id, event.type) ) ): return @@ -492,12 +490,12 @@ def _async_unsubscribe( @callback def async_subscribe_public_event( self, - mac: str, + device_id: str, event_type: EventType, update_callback: Callable[[ProtectEvent], None], ) -> CALLBACK_TYPE: - """Add a callback subscriber for public events of a type by device mac.""" - key = (mac, event_type) + """Add a callback subscriber for public events of a type by device id.""" + key = (device_id, event_type) self._public_event_subscriptions[key].add(update_callback) return partial(self._async_unsubscribe_public_event, key, update_callback) diff --git a/homeassistant/components/unifiprotect/event.py b/homeassistant/components/unifiprotect/event.py index 29d824de65fcc3..4875c4b9697f7d 100644 --- a/homeassistant/components/unifiprotect/event.py +++ b/homeassistant/components/unifiprotect/event.py @@ -86,7 +86,7 @@ async def async_added_to_hass(self) -> None: await super().async_added_to_hass() self.async_on_remove( self.data.async_subscribe_public_event( - self.device.mac, EventType.RING, self._async_ring_event + self.device.id, EventType.RING, self._async_ring_event ) ) @@ -382,7 +382,7 @@ async def async_added_to_hass(self) -> None: await super().async_added_to_hass() self.async_on_remove( self.data.async_subscribe_public_event( - self.device.mac, EventType.SMART_DETECT, self._async_smart_detect_event + self.device.id, EventType.SMART_DETECT, self._async_smart_detect_event ) ) diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 6d1491e41fb272..7789a3face2ff5 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from enum import Enum import logging -from typing import Any, override +from typing import Any, cast, override from uiprotect.api import ProtectApiClient from uiprotect.data import ( @@ -25,7 +25,11 @@ Sensor, Viewer, ) -from uiprotect.data.public_devices import SensorFeatureCapability +from uiprotect.data.public_devices import ( + PublicCamera, + PublicDeviceModel, + SensorFeatureCapability, +) from uiprotect.exceptions import GlobalAlarmManagerError from homeassistant.components.select import SelectEntity, SelectEntityDescription @@ -217,6 +221,16 @@ async def _set_ptz_patrol(obj: Camera, patrol_slot: str) -> None: "always": PublicHdrMode.ON, "off": PublicHdrMode.OFF, } +_HDR_MODE_MAP_INVERSE = {v: k for k, v in _HDR_MODE_MAP.items()} + + +def _get_hdr_mode_public(obj: PublicDeviceModel) -> str | None: + """Return the HDR option id from the public camera's ``hdr_type``. + + ``hdr_type`` is non-optional on the public model; ``.get`` still yields + ``None`` for any value missing from the map. + """ + return _HDR_MODE_MAP_INVERSE.get(cast(PublicCamera, obj).hdr_type) async def _set_hdr_mode(obj: Camera, mode: str) -> None: @@ -282,7 +296,7 @@ async def _set_hdr_mode(obj: Camera, mode: str) -> None: entity_category=EntityCategory.CONFIG, ufp_required_field="feature_flags.has_hdr", ufp_options=HDR_MODES, - ufp_value="hdr_mode_display", + ufp_public_value_fn=_get_hdr_mode_public, ufp_set_method_fn=_set_hdr_mode, ufp_perm=PermRequired.WRITE, ), diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index b01c5bb48e1731..99bd7149d7502a 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -11,7 +11,8 @@ "velbus-parser", "velbus-module", "velbus-packet", - "velbus-protocol" + "velbus-protocol", + "velbus-handler" ], "quality_scale": "silver", "requirements": ["velbus-aio==2026.4.1"], diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 96ea5feda36a4c..3d10be4c647f6c 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -1,7 +1,7 @@ """Support for VeSync fans.""" import logging -from typing import Any, override +from typing import Any, cast, override from pyvesync.base_devices import VeSyncFanBase, VeSyncPurifier @@ -116,7 +116,14 @@ def __init__( ) -> None: """Initialize the fan.""" super().__init__(device, coordinator) - if rgetattr(device, "state.oscillation_status") is not None: + # Tower fans expose a single-axis ``oscillation_status`` state attribute, + # while pedestal fans expose ``vertical_oscillation_status`` and + # ``horizontal_oscillation_status`` separately. The OSCILLATE feature is + # advertised when either form of oscillation is available. + if rgetattr(device, "state.oscillation_status") is not None or ( + rgetattr(device, "state.vertical_oscillation_status") is not None + or rgetattr(device, "state.horizontal_oscillation_status") is not None + ): self._attr_supported_features |= FanEntityFeature.OSCILLATE # Build maps for HA <-> VeSync preset modes self._ha_to_vs_mode_map: dict[str, str] = {} @@ -141,7 +148,14 @@ def is_on(self) -> bool: @override def oscillating(self) -> bool: """Return True if device is oscillating.""" - return rgetattr(self.device, "state.oscillation_status") == "on" + # Tower fans report a single-axis oscillation status. + if rgetattr(self.device, "state.oscillation_status") == "on": + return True + # Pedestal fans report vertical and horizontal oscillation separately; + # the fan is considered oscillating when either axis is active. + if rgetattr(self.device, "state.vertical_oscillation_status") == "on": + return True + return rgetattr(self.device, "state.horizontal_oscillation_status") == "on" @property @override @@ -341,14 +355,28 @@ async def async_turn_off(self, **kwargs: Any) -> None: @override async def async_oscillate(self, oscillating: bool) -> None: """Set oscillation.""" - if hasattr(self.device, "toggle_oscillation"): - success = await self.device.toggle_oscillation(oscillating) - if not success: + # Pedestal fans expose per-axis oscillation; checked first because + # the inherited ``toggle_oscillation`` is a no-op for them. + if ( + rgetattr(self.device, "state.vertical_oscillation_status") is not None + or rgetattr(self.device, "state.horizontal_oscillation_status") is not None + ): + device = cast(VeSyncFanBase, self.device) + vertical_ok = await device.toggle_vertical_oscillation(oscillating) + horizontal_ok = await device.toggle_horizontal_oscillation(oscillating) + if not vertical_ok or not horizontal_ok: if self.device.last_response: raise HomeAssistantError(self.device.last_response.message) raise HomeAssistantError( "Failed to set oscillation, no response found." ) self.async_write_ha_state() - else: + return + if not hasattr(self.device, "toggle_oscillation"): raise HomeAssistantError("Oscillation not supported by this device.") + success = await self.device.toggle_oscillation(oscillating) + if not success: + if self.device.last_response: + raise HomeAssistantError(self.device.last_response.message) + raise HomeAssistantError("Failed to set oscillation, no response found.") + self.async_write_ha_state() diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index 5ae572c9fffb3f..99ab2235bf96b7 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -41,6 +41,7 @@ SERVICE_SET_VICARE_MODE_ATTR_MODE = "vicare_mode" VICARE_MODE_DHW = "dhw" +VICARE_MODE_COOLING = "cooling" VICARE_MODE_HEATING = "heating" VICARE_MODE_HEATINGCOOLING = "heatingCooling" VICARE_MODE_DHWANDHEATING = "dhwAndHeating" @@ -64,6 +65,7 @@ VICARE_MODE_DHWANDHEATING: HVACMode.AUTO, VICARE_MODE_HEATINGCOOLING: HVACMode.AUTO, VICARE_MODE_HEATING: HVACMode.AUTO, + VICARE_MODE_COOLING: HVACMode.COOL, VICARE_MODE_FORCEDNORMAL: HVACMode.HEAT, } diff --git a/homeassistant/components/weatherflow/icons.json b/homeassistant/components/weatherflow/icons.json index 6b691f41c9b376..3d16c457cc44bf 100644 --- a/homeassistant/components/weatherflow/icons.json +++ b/homeassistant/components/weatherflow/icons.json @@ -15,6 +15,15 @@ "lightning_count": { "default": "mdi:lightning-bolt" }, + "lightning_strike_last_distance": { + "default": "mdi:lightning-bolt" + }, + "lightning_strike_last_energy": { + "default": "mdi:lightning-bolt" + }, + "lightning_strike_last_epoch": { + "default": "mdi:lightning-bolt" + }, "precipitation_type": { "default": "mdi:weather-rainy" }, diff --git a/homeassistant/components/weatherflow/sensor.py b/homeassistant/components/weatherflow/sensor.py index 46af3699298fc1..d83eda960baa8a 100644 --- a/homeassistant/components/weatherflow/sensor.py +++ b/homeassistant/components/weatherflow/sensor.py @@ -10,6 +10,7 @@ from pyweatherflowudp.device import ( EVENT_OBSERVATION, EVENT_STATUS_UPDATE, + EVENT_STRIKE, WeatherFlowDevice, WeatherFlowSensorDevice, ) @@ -60,12 +61,13 @@ class WeatherFlowSensorEntityDescription(SensorEntityDescription): raw_data_conv_fn: Callable[[Any], datetime | StateType] + device_attr: str | None = None event_subscriptions: list[str] = field(default_factory=lambda: [EVENT_OBSERVATION]) imperial_suggested_unit: str | None = None def get_native_value(self, device: WeatherFlowDevice) -> datetime | StateType: """Return the parsed sensor value.""" - if (raw_sensor_data := getattr(device, self.key)) is None: + if (raw_sensor_data := getattr(device, self.device_attr or self.key)) is None: return None return self.raw_data_conv_fn(raw_sensor_data) @@ -153,6 +155,33 @@ def get_native_value(self, device: WeatherFlowDevice) -> datetime | StateType: state_class=SensorStateClass.TOTAL, raw_data_conv_fn=lambda raw_data: raw_data, ), + WeatherFlowSensorEntityDescription( + key="lightning_strike_last_distance", + device_attr="last_lightning_strike_event", + translation_key="lightning_strike_last_distance", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.KILOMETERS, + suggested_display_precision=2, + event_subscriptions=[EVENT_STRIKE], + raw_data_conv_fn=lambda raw_data: raw_data.distance.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="lightning_strike_last_energy", + device_attr="last_lightning_strike_event", + translation_key="lightning_strike_last_energy", + state_class=SensorStateClass.MEASUREMENT, + event_subscriptions=[EVENT_STRIKE], + raw_data_conv_fn=lambda raw_data: raw_data.energy, + ), + WeatherFlowSensorEntityDescription( + key="lightning_strike_last_epoch", + device_attr="last_lightning_strike_event", + translation_key="lightning_strike_last_epoch", + device_class=SensorDeviceClass.TIMESTAMP, + event_subscriptions=[EVENT_STRIKE], + raw_data_conv_fn=lambda raw_data: raw_data.timestamp, + ), WeatherFlowSensorEntityDescription( key="precipitation_type", translation_key="precipitation_type", @@ -310,7 +339,7 @@ def async_add_sensor(device: WeatherFlowSensorDevice) -> None: is_metric=(hass.config.units == METRIC_SYSTEM), ) for description in SENSORS - if hasattr(device, description.key) + if hasattr(device, description.device_attr or description.key) ] async_add_entities(sensors) diff --git a/homeassistant/components/weatherflow/strings.json b/homeassistant/components/weatherflow/strings.json index d2146e883703dd..eb562131709446 100644 --- a/homeassistant/components/weatherflow/strings.json +++ b/homeassistant/components/weatherflow/strings.json @@ -48,6 +48,15 @@ "lightning_count": { "name": "Lightning count" }, + "lightning_strike_last_distance": { + "name": "Lightning last distance" + }, + "lightning_strike_last_energy": { + "name": "Lightning last energy" + }, + "lightning_strike_last_epoch": { + "name": "Lightning last strike" + }, "precipitation_type": { "name": "Precipitation type", "state": { diff --git a/homeassistant/components/yardian/switch.py b/homeassistant/components/yardian/switch.py index 853d455844c8b1..760fc99246c636 100644 --- a/homeassistant/components/yardian/switch.py +++ b/homeassistant/components/yardian/switch.py @@ -79,6 +79,6 @@ async def async_turn_on(self, **kwargs: Any) -> None: @override async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - await self.coordinator.controller.stop_irrigation() + await self.coordinator.controller.stop_zone(self._zone_id) await asyncio.sleep(SWITCH_REFRESH_DELAY) await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 32d60b6269ec0d..db6fde0f78715d 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -456,13 +456,13 @@ async def async_step_zeroconf( self._abort_if_unique_id_configured() self.ws_address = f"ws://{discovery_info.host}:{discovery_info.port}" home_id_display = format_home_id_for_display(int(home_id)) - # Show home ID and network location in discovery notification self.context.update( { "title_placeholders": { - "host": discovery_info.host, - "port": str(discovery_info.port), - "home_id": home_id_display, + CONF_NAME: ( + f"Network {home_id_display} at " + f"{discovery_info.host}:{discovery_info.port}" + ) } } ) @@ -1574,8 +1574,9 @@ async def async_step_esphome( ) self.socket_path = discovery_info.socket_path + home_id_display = format_home_id_for_display(discovery_info.zwave_home_id) self.context["title_placeholders"] = { - CONF_NAME: f"{discovery_info.name} via ESPHome" + CONF_NAME: f"Network {home_id_display} via {discovery_info.name} (ESPHome)" } self._adapter_discovered = True diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 48ef507a46a92c..e7f13d332e7405 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -30,7 +30,7 @@ "invalid_ws_url": "Invalid websocket URL", "unknown": "[%key:common::config_flow::error::unknown%]" }, - "flow_title": "Network {home_id} at {host}:{port}", + "flow_title": "{name}", "progress": { "backup_nvm": "Please wait while the network backup completes", "install_addon": "Installation can take several minutes", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 5a112184ba976b..4957ec3ce51aa1 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4652,7 +4652,8 @@ "name": "Netatmo", "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling" + "iot_class": "cloud_polling", + "single_config_entry": true }, "netdata": { "name": "Netdata", diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 1b5f509b66120e..49ef6fe81b9256 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -6,7 +6,6 @@ from datetime import timedelta from decimal import Decimal from enum import Enum -from functools import cache, partial from operator import attrgetter from typing import Any, cast, override @@ -18,13 +17,10 @@ DOMAIN as CALENDAR_DOMAIN, SERVICE_GET_EVENTS, ) -from homeassistant.components.cover import INTENT_CLOSE_COVER, INTENT_OPEN_COVER from homeassistant.components.homeassistant import async_should_expose -from homeassistant.components.intent import async_device_supports_timers from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN from homeassistant.components.sensor import async_rounded_state from homeassistant.components.todo import DOMAIN as TODO_DOMAIN, TodoServices -from homeassistant.components.weather import INTENT_GET_WEATHER from homeassistant.const import ( ATTR_DOMAIN, ATTR_SERVICE, @@ -55,6 +51,8 @@ dict[str, dict[str, tuple[str | None, vol.Schema]]] ] = HassKey("llm_action_parameters_cache") +APIS_CACHE: HassKey[dict[str, API]] = HassKey("llm_apis") + LLM_API_ASSIST = "assist" @@ -73,43 +71,6 @@ "to their voice assistant in Home Assistant." ) -DEVICE_CONTROL_TOOL_USAGE_PROMPT = ( - "When controlling Home Assistant always call the intent tools. " - "Use HassTurnOn to lock and HassTurnOff to unlock a lock. " - "When controlling a device, prefer passing just name and domain. " - "When controlling an area, prefer passing just area name and domain." -) - -DYNAMIC_CONTEXT_PROMPT = ( - "You ARE equipped to answer questions about the" - " current state of\n" - "the home using the `GetLiveContext` tool." - " This is a primary function." - " Do not state you lack the\n" - "functionality if the question requires live data.\n" - "If the user asks about device existence/type" - ' (e.g., "Do I have lights in the bedroom?"):' - " Answer\n" - "from the static context below.\n" - "If the user asks about the CURRENT state, value," - ' or mode (e.g., "Is the lock locked?",\n' - '"Is the fan on?",' - ' "What mode is the thermostat in?",' - ' "What is the temperature outside?"):\n' - " 1. Recognize this requires live data.\n" - " 2. You MUST call `GetLiveContext`." - " This tool will provide the needed real-time" - " information (like temperature from the local" - " weather, lock status, etc.).\n" - " 3. Use the tool's response** to answer the" - " user accurately" - ' (e.g., "The temperature outside is' - ' [value from tool].").\n' - "For general knowledge questions not about the" - " home: Answer truthfully from internal" - " knowledge.\n" -) - @callback def async_render_no_api_prompt(hass: HomeAssistant) -> str: @@ -120,13 +81,15 @@ def async_render_no_api_prompt(hass: HomeAssistant) -> str: return "" -@singleton("llm") +@singleton(APIS_CACHE) @callback def _async_get_apis(hass: HomeAssistant) -> dict[str, API]: - """Get all the LLM APIs.""" - return { - LLM_API_ASSIST: AssistAPI(hass=hass), - } + """Return the registry of LLM APIs. + + APIs are registered by their owning integration; the Assist API is + registered by the ``llm`` integration during setup. + """ + return {} @callback @@ -459,198 +422,6 @@ def merged(x: Any) -> Any: return merged -class AssistAPI(API): - """API exposing Assist API to LLMs.""" - - IGNORE_INTENTS = { - intent.INTENT_GET_TEMPERATURE, - INTENT_GET_WEATHER, - INTENT_OPEN_COVER, # deprecated - INTENT_CLOSE_COVER, # deprecated - intent.INTENT_GET_STATE, - intent.INTENT_NEVERMIND, - intent.INTENT_TOGGLE, - intent.INTENT_GET_CURRENT_DATE, - intent.INTENT_GET_CURRENT_TIME, - intent.INTENT_RESPOND, - } - - def __init__(self, hass: HomeAssistant) -> None: - """Init the class.""" - super().__init__( - hass=hass, - id=LLM_API_ASSIST, - name="Assist", - ) - self.cached_slugify = cache( - partial(unicode_slug.slugify, separator="_", lowercase=False) - ) - - @override - async def async_get_api_instance(self, llm_context: LLMContext) -> APIInstance: - """Return the instance of the API.""" - if llm_context.assistant: - exposed_entities: dict | None = _get_exposed_entities( - self.hass, llm_context.assistant, include_state=False - ) - else: - exposed_entities = None - - return APIInstance( - api=self, - api_prompt=self._async_get_api_prompt(llm_context, exposed_entities), - llm_context=llm_context, - tools=self._async_get_tools(llm_context, exposed_entities), - custom_serializer=selector_serializer, - ) - - @callback - def _async_get_api_prompt( - self, llm_context: LLMContext, exposed_entities: dict | None - ) -> str: - if not exposed_entities or not exposed_entities["entities"]: - return NO_ENTITIES_PROMPT - - # Collect all parts, filtering out any None values - prompt_parts = [ - DEVICE_CONTROL_TOOL_USAGE_PROMPT, - DYNAMIC_CONTEXT_PROMPT, - *self._async_get_exposed_entities_prompt(exposed_entities), - self._async_get_voice_satellite_area_prompt(llm_context), - self._async_get_no_timer_prompt(llm_context), - ] - - # Filter out None and empty strings before joining - return "\n".join([part for part in prompt_parts if part]) - - @callback - def _async_get_no_timer_prompt(self, llm_context: LLMContext) -> str | None: - if not llm_context.device_id or not async_device_supports_timers( - self.hass, llm_context.device_id - ): - return "This device is not able to start timers." - return None - - @callback - def _async_get_voice_satellite_area_prompt(self, llm_context: LLMContext) -> str: - """Return the area prompt for the voice satellite.""" - floor: fr.FloorEntry | None = None - area: ar.AreaEntry | None = None - extra = "" - if llm_context.device_id: - device_reg = dr.async_get(self.hass) - device = device_reg.async_get(llm_context.device_id) - - if device: - area_reg = ar.async_get(self.hass) - if device.area_id and (area := area_reg.async_get_area(device.area_id)): - floor_reg = fr.async_get(self.hass) - if area.floor_id: - floor = floor_reg.async_get_floor(area.floor_id) - - extra = ( - "and all generic commands like" - " 'turn on the lights' should target" - " this area." - ) - - if floor and area: - return f"You are in area {area.name} (floor {floor.name}) {extra}".strip() - if area: - return f"You are in area {area.name} {extra}".strip() - return ( - "When a user asks to turn on all devices of a specific type, " - "ask the user to specify an area, unless there" - " is only one device of that type." - ) - - @callback - def _async_get_exposed_entities_prompt( - self, exposed_entities: dict | None - ) -> list[str]: - """Return the prompt for the API for exposed entities.""" - prompt = [] - - if exposed_entities and exposed_entities["entities"]: - prompt.append( - "Static Context: An overview of the areas" - " and the devices in this smart home:" - ) - prompt.append(yaml_util.dump(list(exposed_entities["entities"].values()))) - - return prompt - - @callback - def _async_get_tools( - self, llm_context: LLMContext, exposed_entities: dict | None - ) -> list[Tool]: - """Return a list of LLM tools.""" - ignore_intents = self.IGNORE_INTENTS - if not llm_context.device_id or not async_device_supports_timers( - self.hass, llm_context.device_id - ): - ignore_intents = ignore_intents | { - intent.INTENT_START_TIMER, - intent.INTENT_CANCEL_TIMER, - intent.INTENT_INCREASE_TIMER, - intent.INTENT_DECREASE_TIMER, - intent.INTENT_PAUSE_TIMER, - intent.INTENT_UNPAUSE_TIMER, - intent.INTENT_TIMER_STATUS, - } - - intent_handlers = [ - intent_handler - for intent_handler in intent.async_get(self.hass) - if intent_handler.intent_type not in ignore_intents - ] - - exposed_domains: set[str] | None = None - if exposed_entities is not None: - exposed_domains = { - info["domain"] for info in exposed_entities["entities"].values() - } - - intent_handlers = [ - intent_handler - for intent_handler in intent_handlers - if intent_handler.platforms is None - or intent_handler.platforms & exposed_domains - ] - - tools: list[Tool] = [ - IntentTool(self.cached_slugify(intent_handler.intent_type), intent_handler) - for intent_handler in intent_handlers - ] - - tools.append(GetDateTimeTool()) - - if exposed_entities: - if exposed_entities[CALENDAR_DOMAIN]: - names = [] - for info in exposed_entities[CALENDAR_DOMAIN].values(): - names.extend(info["names"].split(", ")) - tools.append(CalendarGetEventsTool(names)) - - if exposed_domains is not None and TODO_DOMAIN in exposed_domains: - names = [] - for info in exposed_entities["entities"].values(): - if info["domain"] != TODO_DOMAIN: - continue - names.extend(info["names"].split(", ")) - tools.append(TodoGetItemsTool(names)) - - tools.extend( - ScriptTool(self.hass, script_entity_id) - for script_entity_id in exposed_entities[SCRIPT_DOMAIN] - ) - - if exposed_domains: - tools.append(GetLiveContextTool()) - - return tools - - def _get_exposed_entities( hass: HomeAssistant, assistant: str, diff --git a/requirements_all.txt b/requirements_all.txt index 5dbaa39ef8b6e7..373fa38c1502da 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -7,7 +7,7 @@ AEMET-OpenData==0.6.4 # homeassistant.components.honeywell -AIOSomecomfort==0.0.37 +AIOSomecomfort==0.0.38 # homeassistant.components.adax Adax-local==0.3.0 @@ -2245,7 +2245,7 @@ pyialarm==2.2.0 pyicloud==2.6.5 # homeassistant.components.imou -pyimouapi==1.2.8 +pyimouapi==1.3.0 # homeassistant.components.insteon pyinsteon==1.6.4 diff --git a/tests/components/ai_task/test_task.py b/tests/components/ai_task/test_task.py index a15cec0b466c1e..2a5add49a9bcb1 100644 --- a/tests/components/ai_task/test_task.py +++ b/tests/components/ai_task/test_task.py @@ -16,10 +16,11 @@ from homeassistant.components.ai_task.const import DATA_MEDIA_SOURCE from homeassistant.components.camera import Image from homeassistant.components.conversation import async_get_chat_log +from homeassistant.components.llm import AssistAPI from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import chat_session, llm +from homeassistant.helpers import chat_session from homeassistant.util import dt as dt_util from .conftest import TEST_ENTITY_ID, MockAITaskEntity @@ -77,7 +78,7 @@ async def test_generate_data_preferred_entity( assert state is not None assert state.state == STATE_UNKNOWN - llm_api = llm.AssistAPI(hass) + llm_api = AssistAPI(hass) result = await async_generate_data( hass, task_name="Test Task", diff --git a/tests/components/anthropic/snapshots/test_conversation.ambr b/tests/components/anthropic/snapshots/test_conversation.ambr index fae45df65f9909..bc2af2e7347320 100644 --- a/tests/components/anthropic/snapshots/test_conversation.ambr +++ b/tests/components/anthropic/snapshots/test_conversation.ambr @@ -380,7 +380,7 @@ You are a voice assistant for Home Assistant. Answer questions about the world truthfully. Answer in plain text. Keep it simple and to the point. - Only if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant. + Current time is 16:00:00. Today's date is 2024-06-03. ''', 'created': HAFakeDatetime(2024, 6, 3, 23, 0, tzinfo=datetime.timezone.utc), diff --git a/tests/components/anthropic/test_ai_task.py b/tests/components/anthropic/test_ai_task.py index bfe4e2b4274eef..59aa1f0e498707 100644 --- a/tests/components/anthropic/test_ai_task.py +++ b/tests/components/anthropic/test_ai_task.py @@ -579,3 +579,51 @@ async def test_generate_data_invalid_attachments( {"media_content_id": "media-source://media/doorbell_snapshot.txt"}, ], ) + + +async def test_generate_data_with_attachments_whitespace_instructions( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + mock_create_stream: AsyncMock, +) -> None: + """Test whitespace-only instructions with attachments produce no text block. + + The API rejects whitespace-only text blocks, so the user message should + contain only the attachment. + """ + entity_id = "ai_task.claude_ai_task" + + mock_create_stream.return_value = [create_content_block(0, ["Hi there!"])] + + with ( + patch( + "homeassistant.components.media_source.async_resolve_media", + side_effect=[ + media_source.PlayMedia( + url="http://example.com/doorbell_snapshot.jpg", + mime_type="image/jpg", + path=Path("doorbell_snapshot.jpg"), + ), + ], + ), + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.read_bytes", return_value=b"fake_image_data"), + ): + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions=" ", + attachments=[ + {"media_content_id": "media-source://media/doorbell_snapshot.jpg"}, + ], + ) + + assert result.data == "Hi there!" + + input_messages = mock_create_stream.call_args[1]["messages"] + user_message = input_messages[-2] + assert user_message["role"] == "user" + assert isinstance(user_message["content"], list) + assert [block["type"] for block in user_message["content"]] == ["image"] diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index bf123f6771b1e8..2a0188c4bf1d63 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -1,6 +1,7 @@ """Tests for the Anthropic integration.""" import datetime +from pathlib import Path from typing import Any from unittest.mock import AsyncMock, Mock, patch @@ -56,9 +57,14 @@ CONF_WEB_SEARCH_USER_LOCATION, DOMAIN, ) -from homeassistant.components.anthropic.entity import CitationDetails, ContentDetails +from homeassistant.components.anthropic.entity import ( + CitationDetails, + ContentDetails, + _convert_content, +) from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.intent import async_register_timer_handler +from homeassistant.components.llm import LLMTools from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -319,7 +325,7 @@ async def test_prompt_caching_automatic( assert isinstance(system, str) -@patch("homeassistant.components.anthropic.entity.llm.AssistAPI._async_get_tools") +@patch("homeassistant.components.llm.async_get_tools", new_callable=AsyncMock) @pytest.mark.parametrize( ("tool_call_json_parts", "expected_call_tool_args"), [ @@ -356,7 +362,7 @@ async def test_function_call( ) mock_tool.async_call.return_value = "Test response" - mock_get_tools.return_value = [mock_tool] + mock_get_tools.return_value = LLMTools(tools=[mock_tool]) mock_create_stream.return_value = [ ( @@ -416,7 +422,7 @@ async def test_function_call( ) -@patch("homeassistant.components.anthropic.entity.llm.AssistAPI._async_get_tools") +@patch("homeassistant.components.llm.async_get_tools", new_callable=AsyncMock) async def test_function_exception( mock_get_tools, hass: HomeAssistant, @@ -436,7 +442,7 @@ async def test_function_exception( ) mock_tool.async_call.side_effect = HomeAssistantError("Test tool exception") - mock_get_tools.return_value = [mock_tool] + mock_get_tools.return_value = LLMTools(tools=[mock_tool]) mock_create_stream.return_value = [ ( @@ -847,7 +853,7 @@ async def test_redacted_thinking( assert chat_log.content[1:] == snapshot -@patch("homeassistant.components.anthropic.entity.llm.AssistAPI._async_get_tools") +@patch("homeassistant.components.llm.async_get_tools", new_callable=AsyncMock) async def test_extended_thinking_tool_call( mock_get_tools, hass: HomeAssistant, @@ -879,7 +885,7 @@ async def test_extended_thinking_tool_call( ) mock_tool.async_call.return_value = "Test response" - mock_get_tools.return_value = [mock_tool] + mock_get_tools.return_value = LLMTools(tools=[mock_tool]) mock_create_stream.return_value = [ ( @@ -2392,3 +2398,103 @@ async def test_history_conversion( ) assert mock_create_stream.mock_calls[0][2]["messages"] == snapshot + + +async def test_history_conversion_skips_whitespace_content( + hass: HomeAssistant, + mock_config_entry_with_assist: MockConfigEntry, + mock_init_component, + mock_create_stream: AsyncMock, +) -> None: + """Test that whitespace-only chat log content is not sent to the API. + + The API rejects text content blocks that contain only whitespace, and a + single such entry in a reused chat session would fail every following turn. + """ + conversation_id = "conversation_id" + mock_create_stream.return_value = [create_content_block(0, ["Yes, I am sure!"])] + with ( + chat_session.async_get_chat_session(hass, conversation_id) as session, + conversation.async_get_chat_log(hass, session) as chat_log, + ): + chat_log.content = [ + conversation.chat_log.SystemContent("You are a helpful assistant."), + conversation.chat_log.UserContent("What shape is a donut?"), + conversation.chat_log.AssistantContent( + agent_id="conversation.claude_conversation", content="\n" + ), + conversation.chat_log.UserContent(" "), + conversation.chat_log.AssistantContent( + agent_id="conversation.claude_conversation", content="Round." + ), + ] + + await conversation.async_converse( + hass, + "Are you sure?", + conversation_id, + Context(), + agent_id="conversation.claude_conversation", + ) + + assert mock_create_stream.mock_calls[0][2]["messages"] == [ + {"role": "user", "content": "What shape is a donut?"}, + {"role": "assistant", "content": "Round."}, + {"role": "user", "content": "Are you sure?"}, + {"role": "assistant", "content": "Yes, I am sure!"}, + ] + + +def test_convert_content_whitespace_with_attachments() -> None: + """Test conversion of whitespace-only user content carrying attachments. + + Attachments are only appended to the last message afterwards, so an empty + user message is only created when the content is the last entry; earlier + whitespace-only entries are dropped even if they carry attachments. + """ + attachment = conversation.chat_log.Attachment( + media_content_id="media-source://media/doorbell_snapshot.jpg", + mime_type="image/jpg", + path=Path("doorbell_snapshot.jpg"), + ) + + # Not the last entry: dropped, surrounding user messages are combined + messages, _ = _convert_content( + [ + conversation.chat_log.UserContent("Take a look"), + conversation.chat_log.UserContent(" ", attachments=[attachment]), + conversation.chat_log.UserContent("What do you see?"), + ] + ) + assert messages == [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Take a look"}, + {"type": "text", "text": "What do you see?"}, + ], + }, + ] + + # Last entry preceded by a user message: no text block is added, the + # attachments are appended to the preceding message afterwards + messages, _ = _convert_content( + [ + conversation.chat_log.UserContent("Take a look"), + conversation.chat_log.UserContent(" ", attachments=[attachment]), + ] + ) + assert messages == [ + {"role": "user", "content": "Take a look"}, + ] + + # Last entry with no preceding user message: an empty message is created + # for the attachments to be appended to afterwards + messages, _ = _convert_content( + [ + conversation.chat_log.UserContent(" ", attachments=[attachment]), + ] + ) + assert messages == [ + {"role": "user", "content": []}, + ] diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index ff443061308052..3a85803d721996 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -40,6 +40,7 @@ async_get_pipelines, async_update_pipeline, ) +from homeassistant.components.llm import LLMTools from homeassistant.const import ATTR_FRIENDLY_NAME, MATCH_ALL from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import ( @@ -1822,8 +1823,9 @@ async def stream_llm_response(): with ( patch( - "homeassistant.helpers.llm.AssistAPI._async_get_tools", - return_value=[mock_tool], + "homeassistant.components.llm.async_get_tools", + new_callable=AsyncMock, + return_value=LLMTools(tools=[mock_tool]), ), patch( "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse", diff --git a/tests/components/cloud/snapshots/test_http_api.ambr b/tests/components/cloud/snapshots/test_http_api.ambr index e1cf1902086719..72b13befc4ce41 100644 --- a/tests/components/cloud/snapshots/test_http_api.ambr +++ b/tests/components/cloud/snapshots/test_http_api.ambr @@ -21,7 +21,7 @@ ## Active Integrations - Built-in integrations: 22 + Built-in integrations: 23 Custom integrations: 1
Built-in integrations @@ -42,6 +42,7 @@ homeassistant | Home Assistant Core Integration http | HTTP intent | Intent + llm | LLM media_source | Media Source mock_no_info_integration | mock_no_info_integration repairs | Repairs @@ -156,7 +157,7 @@ ## Active Integrations - Built-in integrations: 22 + Built-in integrations: 23 Custom integrations: 0
Built-in integrations @@ -177,6 +178,7 @@ homeassistant | Home Assistant Core Integration http | HTTP intent | Intent + llm | LLM media_source | Media Source mock_no_info_integration | mock_no_info_integration repairs | Repairs diff --git a/tests/components/conversation/test_chat_log.py b/tests/components/conversation/test_chat_log.py index 164a6aafe5102e..1af92459d04726 100644 --- a/tests/components/conversation/test_chat_log.py +++ b/tests/components/conversation/test_chat_log.py @@ -25,14 +25,22 @@ ChatLogEventType, async_subscribe_chat_logs, ) +from homeassistant.components.llm import LLMTools from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import chat_session, llm +from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed +@pytest.fixture(autouse=True) +async def setup_llm(hass: HomeAssistant) -> None: + """Set up the llm integration so the Assist API can pull its tools.""" + assert await async_setup_component(hass, "llm", {}) + + async def test_cleanup( hass: HomeAssistant, mock_conversation_input: ConversationInput, @@ -434,67 +442,67 @@ async def test_tool_call( ) mock_tool.async_call.return_value = "Test response" - with patch( - "homeassistant.helpers.llm.AssistAPI._async_get_tools", return_value=[] - ) as mock_get_tools: - mock_get_tools.return_value = [mock_tool] + with ( + patch( + "homeassistant.components.llm.async_get_tools", + new_callable=AsyncMock, + return_value=LLMTools(tools=[mock_tool]), + ), + chat_session.async_get_chat_session(hass) as session, + async_get_chat_log(hass, session, mock_conversation_input) as chat_log, + ): + await chat_log.async_provide_llm_data( + mock_conversation_input.as_llm_context("test"), + user_llm_hass_api="assist", + user_llm_prompt=None, + ) + content = AssistantContent( + agent_id=mock_conversation_input.agent_id, + content="", + tool_calls=[ + llm.ToolInput( + id="mock-tool-call-id", + tool_name="test_tool", + tool_args={"param1": "Test Param"}, + ), + llm.ToolInput( + id="mock-tool-call-id-2", + tool_name="test_tool", + tool_args={"param1": "Test Param"}, + ), + ], + ) - with ( - chat_session.async_get_chat_session(hass) as session, - async_get_chat_log(hass, session, mock_conversation_input) as chat_log, - ): - await chat_log.async_provide_llm_data( - mock_conversation_input.as_llm_context("test"), - user_llm_hass_api="assist", - user_llm_prompt=None, + tool_call_tasks = { + tool_call_id: hass.async_create_task( + chat_log.llm_api.async_call_tool(content.tool_calls[0]), + tool_call_id, ) - content = AssistantContent( - agent_id=mock_conversation_input.agent_id, - content="", - tool_calls=[ - llm.ToolInput( - id="mock-tool-call-id", - tool_name="test_tool", - tool_args={"param1": "Test Param"}, - ), - llm.ToolInput( - id="mock-tool-call-id-2", - tool_name="test_tool", - tool_args={"param1": "Test Param"}, - ), - ], - ) - - tool_call_tasks = { - tool_call_id: hass.async_create_task( - chat_log.llm_api.async_call_tool(content.tool_calls[0]), - tool_call_id, - ) - for tool_call_id in prerun_tool_tasks - } - - with pytest.raises(ValueError): - chat_log.async_add_assistant_content_without_tools(content) + for tool_call_id in prerun_tool_tasks + } - results = [ - tool_result_content - async for tool_result_content in chat_log.async_add_assistant_content( - content, tool_call_tasks=tool_call_tasks or None - ) - ] + with pytest.raises(ValueError): + chat_log.async_add_assistant_content_without_tools(content) - assert results[0] == ToolResultContent( - agent_id=mock_conversation_input.agent_id, - tool_call_id="mock-tool-call-id", - tool_result="Test response", - tool_name="test_tool", - ) - assert results[1] == ToolResultContent( - agent_id=mock_conversation_input.agent_id, - tool_call_id="mock-tool-call-id-2", - tool_result="Test response", - tool_name="test_tool", + results = [ + tool_result_content + async for tool_result_content in chat_log.async_add_assistant_content( + content, tool_call_tasks=tool_call_tasks or None ) + ] + + assert results[0] == ToolResultContent( + agent_id=mock_conversation_input.agent_id, + tool_call_id="mock-tool-call-id", + tool_result="Test response", + tool_name="test_tool", + ) + assert results[1] == ToolResultContent( + agent_id=mock_conversation_input.agent_id, + tool_call_id="mock-tool-call-id-2", + tool_result="Test response", + tool_name="test_tool", + ) @freeze_time("2025-10-31 12:00:00") @@ -514,12 +522,13 @@ async def test_tool_call_exception( with ( patch( - "homeassistant.helpers.llm.AssistAPI._async_get_tools", return_value=[] - ) as mock_get_tools, + "homeassistant.components.llm.async_get_tools", + new_callable=AsyncMock, + return_value=LLMTools(tools=[mock_tool]), + ), chat_session.async_get_chat_session(hass) as session, async_get_chat_log(hass, session, mock_conversation_input) as chat_log, ): - mock_get_tools.return_value = [mock_tool] await chat_log.async_provide_llm_data( mock_conversation_input.as_llm_context("test"), user_llm_hass_api="assist", @@ -712,8 +721,10 @@ async def stream(): with ( patch( - "homeassistant.helpers.llm.AssistAPI._async_get_tools", return_value=[] - ) as mock_get_tools, + "homeassistant.components.llm.async_get_tools", + new_callable=AsyncMock, + return_value=LLMTools(tools=[mock_tool]), + ), chat_session.async_get_chat_session(hass) as session, async_get_chat_log( hass, @@ -724,7 +735,6 @@ async def stream(): ), ) as chat_log, ): - mock_get_tools.return_value = [mock_tool] await chat_log.async_provide_llm_data( mock_conversation_input.as_llm_context("test"), user_llm_hass_api="assist", diff --git a/tests/components/environment_canada/test_config_flow.py b/tests/components/environment_canada/test_config_flow.py index 7a5d88b64bf3e4..cd23ae7194bb1c 100644 --- a/tests/components/environment_canada/test_config_flow.py +++ b/tests/components/environment_canada/test_config_flow.py @@ -9,12 +9,16 @@ from homeassistant import config_entries from homeassistant.components.environment_canada.const import ( + CONF_RADAR_DURATION, + CONF_RADAR_FPS, CONF_RADAR_LAYER, CONF_RADAR_LEGEND, CONF_RADAR_OPACITY, CONF_RADAR_RADIUS, CONF_RADAR_TIMESTAMP, CONF_STATION, + DEFAULT_RADAR_DURATION, + DEFAULT_RADAR_FPS, DEFAULT_RADAR_LAYER, DEFAULT_RADAR_LEGEND, DEFAULT_RADAR_OPACITY, @@ -246,6 +250,8 @@ async def test_options_flow_form(hass: HomeAssistant, ec_data: dict[str, Any]) - CONF_RADAR_TIMESTAMP, CONF_RADAR_OPACITY, CONF_RADAR_RADIUS, + CONF_RADAR_DURATION, + CONF_RADAR_FPS, } @@ -261,6 +267,8 @@ async def test_options_flow_save(hass: HomeAssistant, ec_data: dict[str, Any]) - CONF_RADAR_TIMESTAMP: False, CONF_RADAR_OPACITY: 30, CONF_RADAR_RADIUS: 100, + CONF_RADAR_DURATION: 30, + CONF_RADAR_FPS: 10, } with patch( "homeassistant.components.environment_canada.async_setup_entry", @@ -286,6 +294,8 @@ async def test_options_flow_prefills_saved_options( CONF_RADAR_TIMESTAMP: False, CONF_RADAR_OPACITY: 50, CONF_RADAR_RADIUS: 300, + CONF_RADAR_DURATION: 60, + CONF_RADAR_FPS: 15, } config_entry = await init_integration(hass, ec_data, options=saved_options) @@ -297,6 +307,8 @@ async def test_options_flow_prefills_saved_options( assert defaults[CONF_RADAR_TIMESTAMP] is False assert defaults[CONF_RADAR_OPACITY] == 50 assert defaults[CONF_RADAR_RADIUS] == 300 + assert defaults[CONF_RADAR_DURATION] == 60 + assert defaults[CONF_RADAR_FPS] == 15 @pytest.mark.parametrize( @@ -310,6 +322,8 @@ async def test_options_flow_prefills_saved_options( "timestamp": DEFAULT_RADAR_TIMESTAMP, "layer_opacity": DEFAULT_RADAR_OPACITY, "radius": DEFAULT_RADAR_RADIUS, + "loop_minutes": DEFAULT_RADAR_DURATION, + "fps": DEFAULT_RADAR_FPS, }, id="defaults", ), @@ -320,6 +334,8 @@ async def test_options_flow_prefills_saved_options( CONF_RADAR_TIMESTAMP: False, CONF_RADAR_OPACITY: 40.0, CONF_RADAR_RADIUS: 150.0, + CONF_RADAR_DURATION: 30.0, + CONF_RADAR_FPS: 10.0, }, { "layer": "snow", @@ -327,6 +343,8 @@ async def test_options_flow_prefills_saved_options( "timestamp": False, "layer_opacity": 40, "radius": 150, + "loop_minutes": 30, + "fps": 10, }, id="custom", ), @@ -351,3 +369,7 @@ async def test_ecmap_built_from_options( assert isinstance(kwargs["layer_opacity"], int) assert kwargs["radius"] == expected["radius"] assert isinstance(kwargs["radius"], int) + assert kwargs["loop_minutes"] == expected["loop_minutes"] + assert isinstance(kwargs["loop_minutes"], int) + assert kwargs["fps"] == expected["fps"] + assert isinstance(kwargs["fps"], int) diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index 8c495552ac0e08..c83fc9d55b3b2e 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -5,6 +5,7 @@ from http import HTTPStatus import io import socket +import struct from unittest.mock import ANY, AsyncMock, Mock, patch import wave @@ -2428,3 +2429,321 @@ async def async_pipeline_from_audio_stream(*args, **kwargs): await satellite.handle_audio(b"channel 0", b"channel 1") await satellite.handle_pipeline_stop(abort=False) await pipeline_finished.wait() + + +def _make_wav_header( + riff: bytes = b"RIFF", + wave_fmt: bytes = b"WAVE", + chunk_id: bytes = b"fmt ", + chunk_size: int = 16, + audio_format: int = 1, + num_channels: int = 1, + sample_rate: int = 16000, + bits_per_sample: int = 16, + data_chunk_id: bytes = b"data", + data_chunk_size: int = 0, +) -> bytes: + """Build a WAV header for testing.""" + header = struct.pack("<4sI4s", riff, 36 + data_chunk_size, wave_fmt) + header += struct.pack("<4sI", chunk_id, chunk_size) + if chunk_size >= 16: + block_align = num_channels * (bits_per_sample // 8) + byte_rate = sample_rate * block_align + header += struct.pack( + " 16: + header += b"\x00" * (chunk_size - 16) + header += struct.pack("<4sI", data_chunk_id, data_chunk_size) + return header + + +class _ChunkedMockResultStream(MockResultStream): + """MockResultStream that yields pre-defined chunks.""" + + def __init__( + self, hass: HomeAssistant, extension: str, chunks: list[bytes] + ) -> None: + super().__init__(hass, extension, b"") + self.chunks = chunks + + async def async_stream_result(self): + for chunk in self.chunks: + yield chunk + + +@pytest.mark.parametrize( + "wav_data", + [ + pytest.param( + _make_wav_header(riff=b"RIF_"), + id="invalid_riff_header", + ), + pytest.param( + _make_wav_header(audio_format=2), + id="unsupported_audio_format", + ), + pytest.param( + _make_wav_header(num_channels=2), + id="incorrect_channels", + ), + pytest.param( + _make_wav_header(sample_rate=22050), + id="incorrect_sample_rate", + ), + pytest.param( + _make_wav_header(bits_per_sample=8), + id="incorrect_bits_per_sample", + ), + pytest.param( + struct.pack("<4sI4s", b"RIFF", 20, b"WAVE") + + struct.pack("<4sI", b"data", 0), + id="missing_fmt_chunk", + ), + pytest.param( + struct.pack("<4sI4s", b"RIFF", 100, b"WAVE") + + struct.pack("<4sI", b"fmt ", 8) + + b"\x00" * 8 + + struct.pack("<4sI", b"data", 4) + + b"\x01\x02\x03\x04", + id="fmt_chunk_too_small", + ), + ], +) +async def test_stream_tts_audio_invalid_wav( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + wav_data: bytes, +) -> None: + """Test that invalid WAV headers are rejected without sending audio.""" + mock_device = await mock_esphome_device( + mock_client=mock_client, + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + stream = _ChunkedMockResultStream(hass, "wav", [wav_data]) + await satellite._stream_tts_audio(stream) + mock_client.send_voice_assistant_audio.assert_not_called() + + +async def test_stream_tts_audio_junk_chunk_skipping( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test skipping unknown chunks in _stream_tts_audio.""" + mock_device = await mock_esphome_device( + mock_client=mock_client, + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + # Skipping unknown chunks + junk_chunk = struct.pack("<4sI", b"JUNK", 4) + b"junk" + header_with_junk = ( + struct.pack("<4sI4s", b"RIFF", 100, b"WAVE") + + junk_chunk + + struct.pack("<4sI", b"fmt ", 16) + + struct.pack(" None: + """Test fragmentation of incoming stream bytes.""" + mock_device = await mock_esphome_device( + mock_client=mock_client, + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + full_wav = _make_wav_header(data_chunk_size=4) + b"\x01\x02\x03\x04" + byte_chunks = [bytes([b]) for b in full_wav] + stream = _ChunkedMockResultStream(hass, "wav", byte_chunks) + await satellite._stream_tts_audio(stream) + mock_client.send_voice_assistant_audio.assert_called_once_with(b"\x01\x02\x03\x04") + + +async def test_stream_tts_audio_multi_chunk_pacing( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test multi-chunk streaming with sleep/wait logic.""" + mock_device = await mock_esphome_device( + mock_client=mock_client, + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + wav_with_16000bytes = _make_wav_header(data_chunk_size=16000) + b"\x00" * 16000 + stream = _ChunkedMockResultStream(hass, "wav", [wav_with_16000bytes]) + await satellite._stream_tts_audio(stream, samples_per_chunk=512) + assert mock_client.send_voice_assistant_audio.call_count == 16 + for i in range(15): + assert mock_client.send_voice_assistant_audio.call_args_list[i].args == ( + b"\x00" * 1024, + ) + assert mock_client.send_voice_assistant_audio.call_args_list[15].args == ( + b"\x00" * 640, + ) + + +async def test_stream_tts_audio_cancel_between_chunks( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test cancel/abort when self._is_running becomes False (between chunks).""" + mock_device = await mock_esphome_device( + mock_client=mock_client, + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + header_chunk = _make_wav_header(data_chunk_size=8) + + async def async_stream_cancel(): + yield header_chunk + satellite._is_running = False + yield b"\x00" * 8 + + stream = _ChunkedMockResultStream(hass, "wav", []) + stream.async_stream_result = async_stream_cancel + await satellite._stream_tts_audio(stream) + mock_client.send_voice_assistant_audio.assert_not_called() + + +async def test_stream_tts_audio_cancel_inner_loop( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test cancel/abort when self._is_running becomes False (inner chunk loop).""" + mock_device = await mock_esphome_device( + mock_client=mock_client, + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + audio_data = b"\x00" * 2048 + full_wav = _make_wav_header(data_chunk_size=len(audio_data)) + audio_data + + original_send = satellite._send_tts_audio + + def send_then_stop(payload: bytes) -> None: + original_send(payload) + satellite._is_running = False + + stream = _ChunkedMockResultStream(hass, "wav", [full_wav]) + with patch.object(satellite, "_send_tts_audio", side_effect=send_then_stop): + await satellite._stream_tts_audio(stream, samples_per_chunk=512) + assert mock_client.send_voice_assistant_audio.call_count == 1 + + +async def test_stream_tts_audio_odd_junk_padding( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test odd-sized JUNK chunk with RIFF word-alignment padding.""" + mock_device = await mock_esphome_device( + mock_client=mock_client, + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + odd_junk_chunk = ( + struct.pack("<4sI", b"JUNK", 5) + b"junk!" + b"\x00" + ) # 5 bytes + 1 pad + header_with_odd_junk = ( + struct.pack("<4sI4s", b"RIFF", 200, b"WAVE") + + odd_junk_chunk + + struct.pack("<4sI", b"fmt ", 16) + + struct.pack(" None: + """Test trailing metadata after data chunk is not forwarded as audio.""" + mock_device = await mock_esphome_device( + mock_client=mock_client, + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + audio_payload = b"\x11\x22" * 4 # 8 bytes of audio + trailing_list = struct.pack("<4sI", b"LIST", 4) + b"INFO" + wav_with_trailing = ( + _make_wav_header(data_chunk_size=len(audio_payload)) + + audio_payload + + trailing_list + ) + stream = _ChunkedMockResultStream(hass, "wav", [wav_with_trailing]) + await satellite._stream_tts_audio(stream) + mock_client.send_voice_assistant_audio.assert_called_once_with(audio_payload) diff --git a/tests/components/esphome/test_wav_parser.py b/tests/components/esphome/test_wav_parser.py new file mode 100644 index 00000000000000..c53ccaf358846b --- /dev/null +++ b/tests/components/esphome/test_wav_parser.py @@ -0,0 +1,270 @@ +"""Test the ESPHome WAV parser helper.""" + +from collections.abc import AsyncIterable +import io +import struct +import wave + +import pytest + +from homeassistant.components.esphome.wav_parser import stream_wav + + +def _create_wav( + channels: int = 1, + sample_width: int = 2, + sample_rate: int = 16000, + data: bytes = b"\x00" * 1024, +) -> bytes: + """Create a valid WAV file in bytes.""" + with io.BytesIO() as wav_io: + with wave.open(wav_io, "wb") as wav_file: + wav_file.setframerate(sample_rate) + wav_file.setsampwidth(sample_width) + wav_file.setnchannels(channels) + wav_file.writeframes(data) + return wav_io.getvalue() + + +async def _async_generator(data: bytes, chunk_size: int = 128) -> AsyncIterable[bytes]: + """Yield bytes in chunks.""" + for i in range(0, len(data), chunk_size): + yield data[i : i + chunk_size] + + +async def test_stream_wav_valid() -> None: + """Test streaming a valid WAV file.""" + audio_data = b"\x01\x02\x03\x04" * 256 # 1024 bytes + wav_bytes = _create_wav(data=audio_data) + + chunks = [] + async for chunk, is_last in stream_wav( + _async_generator(wav_bytes, chunk_size=100), + expected_format="pcm", + expected_channels=1, + expected_width=2, + expected_sample_rate=16000, + samples_per_chunk=256, + ): + chunks.append((chunk, is_last)) + + # samples_per_chunk = 256, expected_width = 2, expected_channels = 1 + # bytes_per_chunk = 256 * 2 * 1 = 512 bytes + # total audio data = 1024 bytes -> exactly 2 chunks of 512 bytes + assert len(chunks) == 2 + assert chunks[0] == (audio_data[:512], False) + assert chunks[1] == (audio_data[512:], True) + + +async def test_stream_wav_unsupported_format() -> None: + """Test streaming with an unsupported format.""" + wav_bytes = _create_wav() + with pytest.raises(ValueError, match="Unsupported expected format"): + async for _, _ in stream_wav( + _async_generator(wav_bytes), + expected_format="mp3", + expected_channels=1, + expected_width=2, + expected_sample_rate=16000, + ): + pass + + +async def test_stream_wav_invalid_header() -> None: + """Test streaming with an invalid WAV header.""" + invalid_wav = b"RIFFinvalidheader" + with pytest.raises( + ValueError, match="Invalid WAV format: missing RIFF/WAVE header" + ): + async for _, _ in stream_wav( + _async_generator(invalid_wav), + expected_channels=1, + expected_width=2, + expected_sample_rate=16000, + ): + pass + + +async def test_stream_wav_missing_data_chunk() -> None: + """Test streaming a WAV that is missing data chunk.""" + # Write only a fmt chunk + header = b"RIFF" + struct.pack(" None: + """Test parameter validation against fmt chunk.""" + wav_bytes = _create_wav(channels=1, sample_width=2, sample_rate=16000) + + # Wrong channels + with pytest.raises(ValueError, match="Expected 2 channels, got 1"): + async for _, _ in stream_wav( + _async_generator(wav_bytes), + expected_channels=2, + expected_width=2, + expected_sample_rate=16000, + ): + pass + + # Wrong width + with pytest.raises(ValueError, match="Expected 4 bytes per sample, got 2"): + async for _, _ in stream_wav( + _async_generator(wav_bytes), + expected_channels=1, + expected_width=4, + expected_sample_rate=16000, + ): + pass + + # Wrong sample rate + with pytest.raises(ValueError, match="Expected 8000 Hz, got 16000 Hz"): + async for _, _ in stream_wav( + _async_generator(wav_bytes), + expected_channels=1, + expected_width=2, + expected_sample_rate=8000, + ): + pass + + +async def test_stream_wav_non_pcm() -> None: + """Test non-PCM WAV format.""" + header = b"RIFF" + struct.pack(" None: + """Test streaming data chunk without fmt chunk.""" + header = b"RIFF" + struct.pack(" None: + """Test when fmt chunk size is less than 16.""" + header = b"RIFF" + struct.pack(" None: + """Test streaming a WAV file where audio data size is not a multiple of chunk size.""" + audio_data = b"\x01\x02\x03\x04" * 150 # 600 bytes + wav_bytes = _create_wav(data=audio_data) + + chunks = [] + async for chunk, is_last in stream_wav( + _async_generator(wav_bytes, chunk_size=100), + expected_format="pcm", + expected_channels=1, + expected_width=2, + expected_sample_rate=16000, + samples_per_chunk=256, # 512 bytes per chunk + ): + chunks.append((chunk, is_last)) + + # First chunk: 512 bytes, not last + # Second chunk: 88 bytes, last + assert len(chunks) == 2 + assert chunks[0] == (audio_data[:512], False) + assert chunks[1] == (audio_data[512:], True) + + +async def test_stream_wav_small_chunks() -> None: + """Test streaming a WAV file in very small chunks to test partial header parsing.""" + audio_data = b"\x01\x02\x03\x04" * 256 # 1024 bytes + wav_bytes = _create_wav(data=audio_data) + + chunks = [] + async for chunk, is_last in stream_wav( + _async_generator(wav_bytes, chunk_size=5), + expected_format="pcm", + expected_channels=1, + expected_width=2, + expected_sample_rate=16000, + samples_per_chunk=256, + ): + chunks.append((chunk, is_last)) + + assert len(chunks) == 2 + assert chunks[0] == (audio_data[:512], False) + assert chunks[1] == (audio_data[512:], True) + + +async def test_stream_wav_odd_fmt_chunk() -> None: + """Test streaming a WAV file with an odd-sized fmt chunk where the pad byte is in a separate chunk.""" + header = b"RIFF" + struct.pack(" AsyncIterable[bytes]: + yield part1 + yield part2 + + chunks = [] + async for chunk, is_last in stream_wav( + stream_generator(), + expected_channels=1, + expected_width=2, + expected_sample_rate=16000, + samples_per_chunk=2, + ): + chunks.append((chunk, is_last)) + + assert len(chunks) == 1 + assert chunks[0] == (b"\x01\x02\x03\x04", True) diff --git a/tests/components/gree/snapshots/test_climate.ambr b/tests/components/gree/snapshots/test_climate.ambr index 2d7d974e5cddba..c5fd7f80825c5c 100644 --- a/tests/components/gree/snapshots/test_climate.ambr +++ b/tests/components/gree/snapshots/test_climate.ambr @@ -32,13 +32,31 @@ 'none', 'sleep', ]), - : , - : 'off', + : , + : 'default', + : list([ + 'default', + 'full_swing', + 'left', + 'left_center', + 'center', + 'right_center', + 'right', + ]), + : 'default', : list([ - 'off', - 'vertical', - 'horizontal', - 'both', + 'default', + 'full_swing', + 'fixed_upper', + 'fixed_upper_middle', + 'fixed_middle', + 'fixed_lower_middle', + 'fixed_lower', + 'swing_upper', + 'swing_upper_middle', + 'swing_middle', + 'swing_lower_middle', + 'swing_lower', ]), : 1, : 25, @@ -85,11 +103,28 @@ 'none', 'sleep', ]), + : list([ + 'default', + 'full_swing', + 'left', + 'left_center', + 'center', + 'right_center', + 'right', + ]), : list([ - 'off', - 'vertical', - 'horizontal', - 'both', + 'default', + 'full_swing', + 'fixed_upper', + 'fixed_upper_middle', + 'fixed_middle', + 'fixed_lower_middle', + 'fixed_lower', + 'swing_upper', + 'swing_upper_middle', + 'swing_middle', + 'swing_lower_middle', + 'swing_lower', ]), : 1, }), @@ -117,8 +152,8 @@ 'platform': 'gree', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , - 'translation_key': None, + 'supported_features': , + 'translation_key': 'climate', 'unique_id': 'aabbcc112233', 'unit_of_measurement': None, }), diff --git a/tests/components/gree/test_climate.py b/tests/components/gree/test_climate.py index d38044e9bdf27b..539bb6b923d3f2 100644 --- a/tests/components/gree/test_climate.py +++ b/tests/components/gree/test_climate.py @@ -20,6 +20,7 @@ ATTR_FAN_MODE, ATTR_HVAC_MODE, ATTR_PRESET_MODE, + ATTR_SWING_HORIZONTAL_MODE, ATTR_SWING_MODE, DOMAIN as CLIMATE_DOMAIN, FAN_AUTO, @@ -34,18 +35,17 @@ SERVICE_SET_FAN_MODE, SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, + SERVICE_SET_SWING_HORIZONTAL_MODE, SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, - SWING_BOTH, - SWING_HORIZONTAL, - SWING_OFF, - SWING_VERTICAL, HVACMode, ) from homeassistant.components.gree.climate import ( - FAN_MODES_REVERSE, + FAN_MODES_INVERSE, + HORIZONTAL_SWING_MODES_INVERSE, HVAC_MODES, - HVAC_MODES_REVERSE, + HVAC_MODES_INVERSE, + VERTICAL_SWING_MODES_INVERSE, GreeClimateEntity, ) from homeassistant.components.gree.const import ( @@ -425,7 +425,7 @@ async def test_send_target_temperature( hass.config.units = units device().power = True - device().mode = HVAC_MODES_REVERSE.get(HVACMode.AUTO) + device().mode = HVAC_MODES_INVERSE.get(HVACMode.AUTO) fake_device = device() if units.temperature_unit == UnitOfTemperature.FAHRENHEIT: @@ -700,7 +700,7 @@ async def test_update_hvac_mode( ) -> None: """Test for updating hvac mode from the device.""" device().power = hvac_mode != HVACMode.OFF - device().mode = HVAC_MODES_REVERSE.get(hvac_mode) + device().mode = HVAC_MODES_INVERSE.get(hvac_mode) await async_setup_gree(hass) @@ -778,7 +778,7 @@ async def test_update_fan_mode( hass: HomeAssistant, discovery, device, fan_mode ) -> None: """Test for updating fan mode from the device.""" - device().fan_speed = FAN_MODES_REVERSE.get(fan_mode) + device().fan_speed = FAN_MODES_INVERSE.get(fan_mode) await async_setup_gree(hass) @@ -788,10 +788,11 @@ async def test_update_fan_mode( @pytest.mark.parametrize( - "swing_mode", [SWING_OFF, SWING_BOTH, SWING_VERTICAL, SWING_HORIZONTAL] + "swing_mode", + ["default", "full_swing", "fixed_upper", "fixed_lower"], ) async def test_send_swing_mode( - hass: HomeAssistant, discovery, device, swing_mode + hass: HomeAssistant, discovery, device, swing_mode: str ) -> None: """Test for sending swing mode command to the device.""" await async_setup_gree(hass) @@ -826,10 +827,11 @@ async def test_send_invalid_swing_mode(hass: HomeAssistant, discovery, device) - @pytest.mark.parametrize( - "swing_mode", [SWING_OFF, SWING_BOTH, SWING_VERTICAL, SWING_HORIZONTAL] + "swing_mode", + ["default", "full_swing", "fixed_upper", "fixed_lower"], ) async def test_send_swing_mode_device_timeout( - hass: HomeAssistant, discovery, device, swing_mode + hass: HomeAssistant, discovery, device, swing_mode: str ) -> None: """Test for sending swing mode command to the device with a device timeout.""" device().push_state_update.side_effect = DeviceTimeoutError @@ -849,28 +851,134 @@ async def test_send_swing_mode_device_timeout( @pytest.mark.parametrize( - "swing_mode", [SWING_OFF, SWING_BOTH, SWING_VERTICAL, SWING_HORIZONTAL] + "vertical_swing", + [VerticalSwing.Default, VerticalSwing.FullSwing, VerticalSwing.FixedUpper], ) async def test_update_swing_mode( - hass: HomeAssistant, discovery, device, swing_mode + hass: HomeAssistant, discovery, device, vertical_swing: VerticalSwing ) -> None: """Test for updating swing mode from the device.""" - device().horizontal_swing = ( - HorizontalSwing.FullSwing - if swing_mode in (SWING_BOTH, SWING_HORIZONTAL) - else HorizontalSwing.Default + device().vertical_swing = vertical_swing + + await async_setup_gree(hass) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert ( + state.attributes.get(ATTR_SWING_MODE) + == VERTICAL_SWING_MODES_INVERSE[vertical_swing] ) - device().vertical_swing = ( - VerticalSwing.FullSwing - if swing_mode in (SWING_BOTH, SWING_VERTICAL) - else VerticalSwing.Default + + +@pytest.mark.parametrize( + "swing_horizontal_mode", + ["default", "full_swing", "left", "right"], +) +async def test_send_swing_horizontal_mode( + hass: HomeAssistant, discovery, device, swing_horizontal_mode: str +) -> None: + """Test for sending horizontal swing mode command to the device.""" + await async_setup_gree(hass) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_HORIZONTAL_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_SWING_HORIZONTAL_MODE: swing_horizontal_mode}, + blocking=True, ) + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes.get(ATTR_SWING_HORIZONTAL_MODE) == swing_horizontal_mode + + +async def test_send_invalid_swing_horizontal_mode( + hass: HomeAssistant, discovery, device +) -> None: + """Test for sending an invalid horizontal swing mode command to the device.""" await async_setup_gree(hass) + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_HORIZONTAL_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_SWING_HORIZONTAL_MODE: "invalid"}, + blocking=True, + ) + state = hass.states.get(ENTITY_ID) assert state is not None - assert state.attributes.get(ATTR_SWING_MODE) == swing_mode + assert state.attributes.get(ATTR_SWING_HORIZONTAL_MODE) != "invalid" + + +@pytest.mark.parametrize( + "swing_horizontal_mode", + ["default", "full_swing", "left", "right"], +) +async def test_send_swing_horizontal_mode_device_timeout( + hass: HomeAssistant, discovery, device, swing_horizontal_mode: str +) -> None: + """Test for sending horizontal swing mode command to the device with a device timeout.""" + device().push_state_update.side_effect = DeviceTimeoutError + + await async_setup_gree(hass) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_HORIZONTAL_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_SWING_HORIZONTAL_MODE: swing_horizontal_mode}, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes.get(ATTR_SWING_HORIZONTAL_MODE) == swing_horizontal_mode + + +@pytest.mark.parametrize( + "horizontal_swing", + [HorizontalSwing.Default, HorizontalSwing.FullSwing, HorizontalSwing.Left], +) +async def test_update_swing_horizontal_mode( + hass: HomeAssistant, discovery, device, horizontal_swing: HorizontalSwing +) -> None: + """Test for updating horizontal swing mode from the device.""" + device().horizontal_swing = horizontal_swing + + await async_setup_gree(hass) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert ( + state.attributes.get(ATTR_SWING_HORIZONTAL_MODE) + == HORIZONTAL_SWING_MODES_INVERSE[horizontal_swing] + ) + + +async def test_swing_mode_unknown_device_value( + hass: HomeAssistant, discovery, device +) -> None: + """Test that an out-of-range vertical swing value from the device returns None.""" + device().vertical_swing = 99 + + await async_setup_gree(hass) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes.get(ATTR_SWING_MODE) is None + + +async def test_swing_horizontal_mode_unknown_device_value( + hass: HomeAssistant, discovery, device +) -> None: + """Test that an out-of-range horizontal swing value from the device returns None.""" + device().horizontal_swing = 99 + + await async_setup_gree(hass) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes.get(ATTR_SWING_HORIZONTAL_MODE) is None async def test_coordinator_update_handler( diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 193addcfcc2995..d65096090a083c 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -961,7 +961,7 @@ async def test_agents_notify_on_mount_added_removed( "supervisor.backup_request_date": "2025-01-30T05:42:12.345678-08:00", "with_automatic_settings": False, }, - filename=PurePath("Test_2025-01-30_05.42_12345678.tar"), + filename=PurePath("test_2025-01-30_05.42_12345678.tar"), folders={supervisor_backups.Folder("ssl")}, homeassistant_exclude_database=False, homeassistant=True, @@ -1015,6 +1015,16 @@ async def test_agents_notify_on_mount_added_removed( homeassistant_exclude_database=True, ), ), + ( + {"name": "Nabu Casa / Webhook Proxy for HA MCP"}, + replace( + DEFAULT_BACKUP_OPTIONS, + name="Nabu Casa / Webhook Proxy for HA MCP", + filename=PurePath( + "nabu_casa_webhook_proxy_for_ha_mcp_2025-01-30_05.42_12345678.tar" + ), + ), + ), ], ) async def test_reader_writer_create( @@ -1701,7 +1711,7 @@ async def test_reader_writer_create_per_agent_encryption( upload_locations ) for call in supervisor_client.backups.upload_backup.mock_calls: - assert call.args[1].filename == PurePath("Test_2025-01-30_05.42_12345678.tar") + assert call.args[1].filename == PurePath("test_2025-01-30_05.42_12345678.tar") upload_call_locations: set = call.args[1].location assert len(upload_call_locations) == 1 assert upload_call_locations.pop() in upload_locations diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index 6a066cb703d78a..6dbd236771e4ec 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -523,7 +523,9 @@ async def test_update_addon_with_backup_removes_old_backups( update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) -async def test_update_os(hass: HomeAssistant, supervisor_client: AsyncMock) -> None: +async def test_update_os( + hass: HomeAssistant, supervisor_client: AsyncMock, os_info: AsyncMock +) -> None: """Test updating OS update entity.""" config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) config_entry.add_to_hass(hass) @@ -537,7 +539,15 @@ async def test_update_os(hass: HomeAssistant, supervisor_client: AsyncMock) -> N assert result await hass.async_block_till_done() - supervisor_client.os.update.return_value = None + async def mock_os_update(*args: Any) -> None: + """Simulate Supervisor reporting the new version after the update.""" + os_info.return_value = replace( + os_info.return_value, + version="1.0.0dev2222", + update_available=False, + ) + + supervisor_client.os.update.side_effect = mock_os_update with patch( "homeassistant.components.backup.manager.BackupManager.async_create_backup", ) as mock_create_backup: @@ -550,6 +560,13 @@ async def test_update_os(hass: HomeAssistant, supervisor_client: AsyncMock) -> N mock_create_backup.assert_not_called() supervisor_client.os.update.assert_called_once_with(OSUpdate(version=None)) + # The coordinator is refreshed after install so the new version + # shows up immediately + state = hass.states.get("update.home_assistant_operating_system_update") + assert state is not None + assert state.state == "off" + assert state.attributes["installed_version"] == "1.0.0dev2222" + @pytest.mark.parametrize( ("commands", "default_mount", "expected_kwargs"), diff --git a/tests/components/homeassistant/test_llm.py b/tests/components/homeassistant/test_llm.py index 2d888f08c6ed20..019a44543f4f02 100644 --- a/tests/components/homeassistant/test_llm.py +++ b/tests/components/homeassistant/test_llm.py @@ -53,6 +53,22 @@ async def test_no_tools_for_other_api(hass: HomeAssistant) -> None: assert ha_llm.async_get_tools(hass, _llm_context(), "other") is None +async def test_prompt_includes_context(hass: HomeAssistant) -> None: + """Test the platform contributes the live-context and static-overview prompt.""" + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + assert result.prompt is not None + assert ha_llm.DYNAMIC_CONTEXT_PROMPT in result.prompt + assert "Static Context:" in result.prompt + assert "Kitchen Light" in result.prompt + + +async def test_prompt_no_entities(hass: HomeAssistant) -> None: + """Test the platform contributes the no-entities prompt when nothing is exposed.""" + async_expose_entity(hass, "conversation", ENTITY_ID, False) + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + assert result.prompt == llm.NO_ENTITIES_PROMPT + + async def test_get_live_context_no_exposed_entities(hass: HomeAssistant) -> None: """Test GetLiveContext reports an error when nothing is exposed.""" async_expose_entity(hass, "conversation", ENTITY_ID, False) diff --git a/tests/components/homematicip_cloud/conftest.py b/tests/components/homematicip_cloud/conftest.py index 26e359422d0beb..6d882f5e2a4791 100644 --- a/tests/components/homematicip_cloud/conftest.py +++ b/tests/components/homematicip_cloud/conftest.py @@ -171,6 +171,17 @@ def full_flush_lock_controller_device_data_fixture() -> dict[str, Any]: "label": "", "supportedOptionalFeatures": {}, }, + "9": { + "authorized": True, + "channelRole": "DOOR_OPENER_ACTUATOR", + "deviceId": "3014F7110000000000000026", + "functionalChannelType": "ACCESS_AUTHORIZATION_CHANNEL", + "groupIndex": 4, + "groups": [], + "index": 9, + "label": "", + "supportedOptionalFeatures": {}, + }, }, "homeId": "00000000-0000-0000-0000-000000000001", "id": "3014F7110000000000000026", diff --git a/tests/components/homematicip_cloud/test_button.py b/tests/components/homematicip_cloud/test_button.py index a1eb06a886176e..7ea135c1a31627 100644 --- a/tests/components/homematicip_cloud/test_button.py +++ b/tests/components/homematicip_cloud/test_button.py @@ -1,6 +1,7 @@ """Tests for HomematicIP Cloud button.""" from typing import Any +from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory @@ -51,7 +52,14 @@ async def test_hmip_full_flush_lock_controller_button( default_mock_hap_factory: HomeFactory, full_flush_lock_controller_device_data: dict[str, Any], ) -> None: - """Test HomematicIP full flush lock controller opener button.""" + """Test HomematicIP full flush lock controller opener button. + + The button must pull the latch on the ACCESS_AUTHORIZATION_CHANNEL with + role DOOR_OPENER_ACTUATOR (channel 9 in the fixture), not call + send_start_impulse on the underlying DOOR_SWITCH_CHANNEL. The former is + the only endpoint non-admin clients are allowed to invoke; the latter + fails with CLIENT_ACCESS_DENIED for non-admin clients. + """ entity_id = "button.universal_motorschloss_controller_door_opener" entity_name = "Universal Motorschloss Controller Door opener" device_model = "HmIP-FLC" @@ -66,18 +74,53 @@ async def test_hmip_full_flush_lock_controller_button( assert state assert state.state == STATE_UNKNOWN - now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") - freezer.move_to(now) - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, + hmip_device = mock_hap.hmip_device_by_entity_id[entity_id] + auth_channel = next( + ch + for ch in hmip_device.functionalChannels + if ch.functionalChannelType.name == "ACCESS_AUTHORIZATION_CHANNEL" + and ch.channelRole == "DOOR_OPENER_ACTUATOR" ) - hmip_device = mock_hap.hmip_device_by_entity_id[entity_id] - assert hmip_device.mock_calls[-1][0] == "send_start_impulse_async" + with ( + patch.object( + auth_channel, "async_pull_latch", new_callable=AsyncMock + ) as mock_pull_latch, + patch.object( + hmip_device, "send_start_impulse_async", new_callable=AsyncMock + ) as mock_send_start_impulse, + ): + now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") + freezer.move_to(now) + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mock_pull_latch.assert_awaited_once_with() + mock_send_start_impulse.assert_not_awaited() state = hass.states.get(entity_id) assert state assert state.state == now.isoformat() + + +async def test_hmip_full_flush_lock_controller_button_missing_channel( + hass: HomeAssistant, + default_mock_hap_factory: HomeFactory, + full_flush_lock_controller_device_data: dict[str, Any], +) -> None: + """Button is not created when the door-opener auth channel is missing.""" + # Strip the access-authorization channel for DOOR_OPENER_ACTUATOR so the + # setup detection function rejects the device. + full_flush_lock_controller_device_data["functionalChannels"].pop("9") + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["Universal Motorschloss Controller"], + extra_devices=[full_flush_lock_controller_device_data], + ) + + entity_id = "button.universal_motorschloss_controller_door_opener" + assert hass.states.get(entity_id) is None + assert entity_id not in mock_hap.hmip_device_by_entity_id diff --git a/tests/components/homematicip_cloud/test_sensor.py b/tests/components/homematicip_cloud/test_sensor.py index f09a5b84b884ba..8ba9773c0d30ba 100644 --- a/tests/components/homematicip_cloud/test_sensor.py +++ b/tests/components/homematicip_cloud/test_sensor.py @@ -1,6 +1,7 @@ """Tests for HomematicIP Cloud sensor.""" from homematicip.base.enums import ValveState, WindowState +import pytest from homeassistant.components.homematicip_cloud import DOMAIN from homeassistant.components.homematicip_cloud.entity import ( @@ -340,6 +341,29 @@ async def test_hmip_illuminance_sensor2( assert ha_state.attributes[ATTR_LOWEST_ILLUMINATION] == 785.2 +async def test_hmip_motion_detector_push_button_single_illuminance( + hass: HomeAssistant, + default_mock_hap_factory: HomeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test MotionDetectorPushButton produces exactly one illuminance sensor.""" + await default_mock_hap_factory.async_get_mock_hap( + test_devices=["Bewegungsmelder fĂ¼r 55er Rahmen – innen"] + ) + illuminance_states = [ + state + for state in hass.states.async_all("sensor") + if state.entity_id.endswith("_illuminance") + ] + assert len(illuminance_states) == 1 + assert ( + illuminance_states[0].entity_id + == "sensor.bewegungsmelder_fur_55er_rahmen_innen_illuminance" + ) + assert illuminance_states[0].state == "14.2" + assert "does not generate unique IDs" not in caplog.text + + async def test_hmip_windspeed_sensor( hass: HomeAssistant, default_mock_hap_factory: HomeFactory ) -> None: diff --git a/tests/components/intent/test_llm.py b/tests/components/intent/test_llm.py index 6db25c1b6ab19d..e3b5a79764b47e 100644 --- a/tests/components/intent/test_llm.py +++ b/tests/components/intent/test_llm.py @@ -78,6 +78,23 @@ async def test_set_position_requires_exposed_cover(hass: HomeAssistant) -> None: assert "HassSetPosition" not in await _tool_names(hass) +async def test_prompt_includes_device_control(hass: HomeAssistant) -> None: + """Test the platform contributes device-control guidance when exposed.""" + result = intent_llm.async_get_tools(hass, _llm_context(), "assist") + assert result is not None + assert result.prompt is not None + assert intent_llm.DEVICE_CONTROL_TOOL_USAGE_PROMPT in result.prompt + assert "This device is not able to start timers." in result.prompt + + +async def test_no_prompt_without_exposed_entities(hass: HomeAssistant) -> None: + """Test the platform contributes no prompt when nothing is exposed.""" + async_expose_entity(hass, "conversation", COVER_ENTITY_ID, False) + result = intent_llm.async_get_tools(hass, _llm_context(), "assist") + assert result is not None + assert result.prompt is None + + async def test_no_tools_for_other_api(hass: HomeAssistant) -> None: """Test the platform returns None for an unsupported API.""" assert intent_llm.async_get_tools(hass, _llm_context(), "other") is None diff --git a/tests/components/kiosker/test_config_flow.py b/tests/components/kiosker/test_config_flow.py index 4dd0a57ca205c0..72ab7740bebbc5 100644 --- a/tests/components/kiosker/test_config_flow.py +++ b/tests/components/kiosker/test_config_flow.py @@ -275,3 +275,75 @@ async def test_user_flow_no_device_id( ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_reauth_flow_success( + hass: HomeAssistant, + mock_kiosker_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow updates the token and reloads.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_TOKEN: "new-token"}, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_API_TOKEN] == "new-token" + + +async def test_reauth_flow_wrong_device( + hass: HomeAssistant, + mock_kiosker_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow aborts when a different device is found at the host.""" + mock_config_entry.add_to_hass(hass) + mock_kiosker_api.status.return_value.device_id = "DIFFERENT-DEVICE-ID" + + result = await mock_config_entry.start_reauth_flow(hass) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_TOKEN: "new-token"}, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "wrong_device" + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + pytest.param(ConnectionError, "cannot_connect", id="connection_error"), + pytest.param(AuthenticationError, "invalid_auth", id="auth_error"), + pytest.param(IPAuthenticationError, "invalid_ip_auth", id="ip_auth_error"), + pytest.param(TLSVerificationError, "tls_error", id="tls_error"), + pytest.param(BadRequestError, "bad_request", id="bad_request"), + ], +) +async def test_reauth_flow_errors( + hass: HomeAssistant, + mock_kiosker_api: MagicMock, + mock_config_entry: MockConfigEntry, + side_effect: type[Exception], + expected_error: str, +) -> None: + """Test reauth flow shows correct error for each API exception.""" + mock_config_entry.add_to_hass(hass) + mock_kiosker_api.status.side_effect = side_effect + + result = await mock_config_entry.start_reauth_flow(hass) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_TOKEN: "bad-token"}, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": expected_error} diff --git a/tests/components/melcloud_home/fixtures/context.json b/tests/components/melcloud_home/fixtures/context.json index 55a555ffef969e..db95462950fb1a 100644 --- a/tests/components/melcloud_home/fixtures/context.json +++ b/tests/components/melcloud_home/fixtures/context.json @@ -32,7 +32,7 @@ "hasAirDirection": true, "hasSwing": true, "hasExtendedTemperatureRange": true, - "hasEnergyConsumedMeter": false, + "hasEnergyConsumedMeter": true, "numberOfFanSpeeds": 5, "minTempCool": 16, "maxTempCool": 31, @@ -195,7 +195,7 @@ "rssi": -52, "frostProtection": { "active": false, - "enabled": false, + "enabled": true, "min": 5, "max": 8 }, diff --git a/tests/components/melcloud_home/snapshots/test_climate.ambr b/tests/components/melcloud_home/snapshots/test_climate.ambr index 3d55e0981f43f4..7c542c400d28bb 100644 --- a/tests/components/melcloud_home/snapshots/test_climate.ambr +++ b/tests/components/melcloud_home/snapshots/test_climate.ambr @@ -158,8 +158,8 @@ , , ]), - : 35, - : 7, + : 31.0, + : 10.0, : list([ 'auto', 'swing', @@ -231,8 +231,8 @@ , , ]), - : 35, - : 7, + : 31.0, + : 10.0, : , : 'centre', : list([ diff --git a/tests/components/melcloud_home/snapshots/test_diagnostics.ambr b/tests/components/melcloud_home/snapshots/test_diagnostics.ambr index 0cf362c7bf11d3..e6e1255a61ffc6 100644 --- a/tests/components/melcloud_home/snapshots/test_diagnostics.ambr +++ b/tests/components/melcloud_home/snapshots/test_diagnostics.ambr @@ -33,7 +33,7 @@ 'has_auto_operation_mode': True, 'has_cool_operation_mode': True, 'has_dry_operation_mode': True, - 'has_energy_consumed_meter': False, + 'has_energy_consumed_meter': True, 'has_fan_operation_mode': None, 'has_half_degree_increments': True, 'has_outdoor_temperature_sensor': False, @@ -153,7 +153,7 @@ 'forced_hot_water_mode': False, 'frost_protection': dict({ 'active': False, - 'enabled': False, + 'enabled': True, 'max': 8.0, 'min': 5.0, }), diff --git a/tests/components/melcloud_home/snapshots/test_number.ambr b/tests/components/melcloud_home/snapshots/test_number.ambr new file mode 100644 index 00000000000000..8a757dd273107d --- /dev/null +++ b/tests/components/melcloud_home/snapshots/test_number.ambr @@ -0,0 +1,489 @@ +# serializer version: 1 +# name: test_all_entities[number.heat_pump_frost_protection_maximum_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 30.0, + : 0.0, + : , + : 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.heat_pump_frost_protection_maximum_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Frost protection maximum temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frost protection maximum temperature', + 'platform': 'melcloud_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'frost_protection_max_temp', + 'unique_id': 'atw-unit-uuid-1_frost_protection_max_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.heat_pump_frost_protection_maximum_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Heat Pump Frost protection maximum temperature', + : 30.0, + : 0.0, + : , + : 0.5, + : , + }), + 'context': , + 'entity_id': 'number.heat_pump_frost_protection_maximum_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8.0', + }) +# --- +# name: test_all_entities[number.heat_pump_frost_protection_minimum_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 30.0, + : 0.0, + : , + : 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.heat_pump_frost_protection_minimum_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Frost protection minimum temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frost protection minimum temperature', + 'platform': 'melcloud_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'frost_protection_min_temp', + 'unique_id': 'atw-unit-uuid-1_frost_protection_min_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.heat_pump_frost_protection_minimum_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Heat Pump Frost protection minimum temperature', + : 30.0, + : 0.0, + : , + : 0.5, + : , + }), + 'context': , + 'entity_id': 'number.heat_pump_frost_protection_minimum_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- +# name: test_all_entities[number.heat_pump_overheat_protection_maximum_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 60.0, + : 20.0, + : , + : 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.heat_pump_overheat_protection_maximum_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Overheat protection maximum temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overheat protection maximum temperature', + 'platform': 'melcloud_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'overheat_protection_max_temp', + 'unique_id': 'atw-unit-uuid-1_overheat_protection_max_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.heat_pump_overheat_protection_maximum_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Heat Pump Overheat protection maximum temperature', + : 60.0, + : 20.0, + : , + : 0.5, + : , + }), + 'context': , + 'entity_id': 'number.heat_pump_overheat_protection_maximum_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42.0', + }) +# --- +# name: test_all_entities[number.heat_pump_overheat_protection_minimum_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 60.0, + : 20.0, + : , + : 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.heat_pump_overheat_protection_minimum_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Overheat protection minimum temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overheat protection minimum temperature', + 'platform': 'melcloud_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'overheat_protection_min_temp', + 'unique_id': 'atw-unit-uuid-1_overheat_protection_min_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.heat_pump_overheat_protection_minimum_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Heat Pump Overheat protection minimum temperature', + : 60.0, + : 20.0, + : , + : 0.5, + : , + }), + 'context': , + 'entity_id': 'number.heat_pump_overheat_protection_minimum_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40.0', + }) +# --- +# name: test_all_entities[number.living_room_ac_frost_protection_maximum_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 30.0, + : 0.0, + : , + : 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.living_room_ac_frost_protection_maximum_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Frost protection maximum temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frost protection maximum temperature', + 'platform': 'melcloud_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'frost_protection_max_temp', + 'unique_id': 'ata-unit-uuid-1_frost_protection_max_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.living_room_ac_frost_protection_maximum_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Living Room AC Frost protection maximum temperature', + : 30.0, + : 0.0, + : , + : 0.5, + : , + }), + 'context': , + 'entity_id': 'number.living_room_ac_frost_protection_maximum_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.0', + }) +# --- +# name: test_all_entities[number.living_room_ac_frost_protection_minimum_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 30.0, + : 0.0, + : , + : 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.living_room_ac_frost_protection_minimum_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Frost protection minimum temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frost protection minimum temperature', + 'platform': 'melcloud_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'frost_protection_min_temp', + 'unique_id': 'ata-unit-uuid-1_frost_protection_min_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.living_room_ac_frost_protection_minimum_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Living Room AC Frost protection minimum temperature', + : 30.0, + : 0.0, + : , + : 0.5, + : , + }), + 'context': , + 'entity_id': 'number.living_room_ac_frost_protection_minimum_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_all_entities[number.living_room_ac_overheat_protection_maximum_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 40.0, + : 31.0, + : , + : 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.living_room_ac_overheat_protection_maximum_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Overheat protection maximum temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overheat protection maximum temperature', + 'platform': 'melcloud_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'overheat_protection_max_temp', + 'unique_id': 'ata-unit-uuid-1_overheat_protection_max_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.living_room_ac_overheat_protection_maximum_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Living Room AC Overheat protection maximum temperature', + : 40.0, + : 31.0, + : , + : 0.5, + : , + }), + 'context': , + 'entity_id': 'number.living_room_ac_overheat_protection_maximum_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '37.0', + }) +# --- +# name: test_all_entities[number.living_room_ac_overheat_protection_minimum_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 40.0, + : 31.0, + : , + : 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.living_room_ac_overheat_protection_minimum_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Overheat protection minimum temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overheat protection minimum temperature', + 'platform': 'melcloud_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'overheat_protection_min_temp', + 'unique_id': 'ata-unit-uuid-1_overheat_protection_min_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.living_room_ac_overheat_protection_minimum_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Living Room AC Overheat protection minimum temperature', + : 40.0, + : 31.0, + : , + : 0.5, + : , + }), + 'context': , + 'entity_id': 'number.living_room_ac_overheat_protection_minimum_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35.0', + }) +# --- diff --git a/tests/components/melcloud_home/snapshots/test_sensor.ambr b/tests/components/melcloud_home/snapshots/test_sensor.ambr index c2952d0ec309dc..755967d4fe2064 100644 --- a/tests/components/melcloud_home/snapshots/test_sensor.ambr +++ b/tests/components/melcloud_home/snapshots/test_sensor.ambr @@ -290,6 +290,68 @@ 'state': '21.0', }) # --- +# name: test_all_entities[sensor.living_room_ac_energy_consumed_monthly-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.living_room_ac_energy_consumed_monthly', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy consumed (monthly)', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy consumed (monthly)', + 'platform': 'melcloud_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_consumed', + 'unique_id': 'ata-unit-uuid-1_energy_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.living_room_ac_energy_consumed_monthly-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Living Room AC Energy consumed (monthly)', + : '2026-06-01T00:00:00+00:00', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.living_room_ac_energy_consumed_monthly', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.4505', + }) +# --- # name: test_all_entities[sensor.living_room_ac_room_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/melcloud_home/snapshots/test_switch.ambr b/tests/components/melcloud_home/snapshots/test_switch.ambr index 4d3f0f73f2f2f2..a75a0b091d8cb7 100644 --- a/tests/components/melcloud_home/snapshots/test_switch.ambr +++ b/tests/components/melcloud_home/snapshots/test_switch.ambr @@ -47,7 +47,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', }) # --- # name: test_all_entities[switch.heat_pump_overheat_protection-entry] diff --git a/tests/components/melcloud_home/test_climate.py b/tests/components/melcloud_home/test_climate.py index f728d6d3047335..6da75604beac14 100644 --- a/tests/components/melcloud_home/test_climate.py +++ b/tests/components/melcloud_home/test_climate.py @@ -438,3 +438,91 @@ async def test_atw_turn_on_off( mock_melcloud_client.control_atw_unit.assert_called_once_with( "atw-unit-uuid-1", **expected_kwargs ) + + +@pytest.mark.parametrize( + ("operation_mode", "expected_min", "expected_max"), + [ + pytest.param("Cool", 16.0, 31.0, id="cool"), + pytest.param("Automatic", 16.0, 31.0, id="auto"), + pytest.param("Fan", 7, 35, id="fan_only"), + ], +) +async def test_ata_temperature_range_by_mode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_melcloud_client: AsyncMock, + operation_mode: str, + expected_min: float, + expected_max: float, +) -> None: + """Test ATA min/max temperature for cool, auto, and fallback HVAC modes.""" + context: dict[str, Any] = await async_load_json_object_fixture( + hass, "context.json", DOMAIN + ) + next( + setting + for setting in context["buildings"][0]["airToAirUnits"][0]["settings"] + if setting["name"] == "OperationMode" + )["value"] = operation_mode + mock_melcloud_client.get_context.return_value = UserContext.model_validate(context) + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get(ATA_ENTITY_ID) + assert state is not None + assert state.attributes["min_temp"] == expected_min + assert state.attributes["max_temp"] == expected_max + + +async def test_ata_no_capabilities_temperature_range( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_melcloud_client: AsyncMock, +) -> None: + """Test fallback temperature range and hvac_modes when ATA unit has no capabilities.""" + context: dict[str, Any] = await async_load_json_object_fixture( + hass, "context.json", DOMAIN + ) + context["buildings"][0]["airToAirUnits"][0]["capabilities"] = None + mock_melcloud_client.get_context.return_value = UserContext.model_validate(context) + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get(ATA_ENTITY_ID) + assert state is not None + assert state.attributes["min_temp"] == 7 + assert state.attributes["max_temp"] == 35 + assert HVACMode.FAN_ONLY in state.attributes["hvac_modes"] + + +async def test_atw_zone_temperature_range( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_melcloud_client: AsyncMock, +) -> None: + """Test ATW zone min/max temperature read from unit capabilities.""" + context: dict[str, Any] = await async_load_json_object_fixture( + hass, "context.json", DOMAIN + ) + context["buildings"][0]["airToWaterUnits"][0]["capabilities"].update( + { + "minSetTemperatureZone1": 10.0, + "maxSetTemperatureZone1": 30.0, + "minSetTemperatureZone2": 12.0, + "maxSetTemperatureZone2": 28.0, + } + ) + mock_melcloud_client.get_context.return_value = UserContext.model_validate(context) + + await setup_integration(hass, mock_config_entry) + + state1 = hass.states.get(ATW_ZONE1_ENTITY_ID) + assert state1 is not None + assert state1.attributes["min_temp"] == 10.0 + assert state1.attributes["max_temp"] == 30.0 + + state2 = hass.states.get(ATW_ZONE2_ENTITY_ID) + assert state2 is not None + assert state2.attributes["min_temp"] == 12.0 + assert state2.attributes["max_temp"] == 28.0 diff --git a/tests/components/melcloud_home/test_number.py b/tests/components/melcloud_home/test_number.py new file mode 100644 index 00000000000000..d9ce0d16b1b445 --- /dev/null +++ b/tests/components/melcloud_home/test_number.py @@ -0,0 +1,310 @@ +"""Tests for the MELCloud Home number platform.""" + +from unittest.mock import AsyncMock, patch + +from aiomelcloudhome.exceptions import ( + MelCloudHomeAuthenticationError, + MelCloudHomeConnectionError, + MelCloudHomeTimeoutError, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def enable_all_entities(entity_registry_enabled_by_default: None) -> None: + """Make sure all entities are enabled.""" + + +@pytest.mark.usefixtures("mock_melcloud_client") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all number entities.""" + with patch( + "homeassistant.components.melcloud_home.PLATFORMS", + [Platform.NUMBER], + ): + await setup_integration(hass, mock_config_entry) + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + ) + + +@pytest.mark.parametrize( + ("entity_id", "method", "expected_kwargs", "value"), + [ + ( + "number.living_room_ac_frost_protection_minimum_temperature", + "set_frost_protection", + { + "enabled": True, + "min_temp": 11.0, + "max_temp": 12.0, + "ata_unit_ids": ["ata-unit-uuid-1"], + }, + 11.0, + ), + ( + "number.living_room_ac_frost_protection_maximum_temperature", + "set_frost_protection", + { + "enabled": True, + "min_temp": 10.0, + "max_temp": 13.0, + "ata_unit_ids": ["ata-unit-uuid-1"], + }, + 13.0, + ), + ( + "number.living_room_ac_overheat_protection_minimum_temperature", + "set_overheat_protection", + { + "enabled": True, + "min_temp": 36.0, + "max_temp": 37.0, + "ata_unit_ids": ["ata-unit-uuid-1"], + }, + 36.0, + ), + ( + "number.living_room_ac_overheat_protection_maximum_temperature", + "set_overheat_protection", + { + "enabled": True, + "min_temp": 35.0, + "max_temp": 38.0, + "ata_unit_ids": ["ata-unit-uuid-1"], + }, + 38.0, + ), + ( + "number.heat_pump_frost_protection_minimum_temperature", + "set_frost_protection", + { + "enabled": True, + "min_temp": 6.0, + "max_temp": 8.0, + "atw_unit_ids": ["atw-unit-uuid-1"], + }, + 6.0, + ), + ( + "number.heat_pump_frost_protection_maximum_temperature", + "set_frost_protection", + { + "enabled": True, + "min_temp": 5.0, + "max_temp": 9.0, + "atw_unit_ids": ["atw-unit-uuid-1"], + }, + 9.0, + ), + ( + "number.heat_pump_overheat_protection_minimum_temperature", + "set_overheat_protection", + { + "enabled": True, + "min_temp": 41.0, + "max_temp": 42.0, + "atw_unit_ids": ["atw-unit-uuid-1"], + }, + 41.0, + ), + ( + "number.heat_pump_overheat_protection_maximum_temperature", + "set_overheat_protection", + { + "enabled": True, + "min_temp": 40.0, + "max_temp": 43.0, + "atw_unit_ids": ["atw-unit-uuid-1"], + }, + 43.0, + ), + ], +) +async def test_set_value( + hass: HomeAssistant, + mock_melcloud_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_id: str, + method: str, + expected_kwargs: dict, + value: float, +) -> None: + """Test number set_value calls the correct API method.""" + await setup_integration(hass, mock_config_entry) + + method_mock = getattr(mock_melcloud_client, method) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}, + blocking=True, + ) + + method_mock.assert_called_once_with(**expected_kwargs) + + +@pytest.mark.parametrize( + ("entity_id", "value"), + [ + ( + "number.living_room_ac_frost_protection_minimum_temperature", + 12.0, + ), + ( + "number.living_room_ac_frost_protection_minimum_temperature", + 13.0, + ), + ( + "number.living_room_ac_frost_protection_maximum_temperature", + 10.0, + ), + ( + "number.living_room_ac_frost_protection_maximum_temperature", + 9.0, + ), + ( + "number.living_room_ac_overheat_protection_minimum_temperature", + 37.0, + ), + ( + "number.living_room_ac_overheat_protection_maximum_temperature", + 35.0, + ), + ( + "number.heat_pump_frost_protection_minimum_temperature", + 8.0, + ), + ( + "number.heat_pump_frost_protection_maximum_temperature", + 5.0, + ), + ( + "number.heat_pump_overheat_protection_minimum_temperature", + 42.0, + ), + ( + "number.heat_pump_overheat_protection_maximum_temperature", + 40.0, + ), + ], +) +async def test_set_value_validation_error( + hass: HomeAssistant, + mock_melcloud_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_id: str, + value: float, +) -> None: + """Test that setting min >= max or max <= min raises HomeAssistantError.""" + await setup_integration(hass, mock_config_entry) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}, + blocking=True, + ) + + mock_melcloud_client.set_frost_protection.assert_not_called() + mock_melcloud_client.set_overheat_protection.assert_not_called() + + +@pytest.mark.parametrize( + ("entity_id", "method", "value"), + [ + ( + "number.living_room_ac_frost_protection_minimum_temperature", + "set_frost_protection", + 10.0, + ), + ( + "number.living_room_ac_frost_protection_maximum_temperature", + "set_frost_protection", + 12.0, + ), + ( + "number.living_room_ac_overheat_protection_minimum_temperature", + "set_overheat_protection", + 35.0, + ), + ( + "number.living_room_ac_overheat_protection_maximum_temperature", + "set_overheat_protection", + 37.0, + ), + ( + "number.heat_pump_frost_protection_minimum_temperature", + "set_frost_protection", + 5.0, + ), + ( + "number.heat_pump_frost_protection_maximum_temperature", + "set_frost_protection", + 8.0, + ), + ( + "number.heat_pump_overheat_protection_minimum_temperature", + "set_overheat_protection", + 40.0, + ), + ( + "number.heat_pump_overheat_protection_maximum_temperature", + "set_overheat_protection", + 42.0, + ), + ], +) +@pytest.mark.parametrize( + ("raise_exception", "expected_exception"), + [ + (MelCloudHomeAuthenticationError, HomeAssistantError), + (MelCloudHomeConnectionError, HomeAssistantError), + (MelCloudHomeTimeoutError, HomeAssistantError), + ], +) +async def test_set_value_exceptions( + hass: HomeAssistant, + mock_melcloud_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_id: str, + method: str, + value: float, + raise_exception: type[Exception], + expected_exception: type[Exception], +) -> None: + """Test number actions raise HomeAssistantError on client errors.""" + await setup_integration(hass, mock_config_entry) + + method_mock = getattr(mock_melcloud_client, method) + method_mock.side_effect = raise_exception + + with pytest.raises(expected_exception): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}, + blocking=True, + ) diff --git a/tests/components/netatmo/test_config_flow.py b/tests/components/netatmo/test_config_flow.py index 9773b70294334f..743ec43d61301c 100644 --- a/tests/components/netatmo/test_config_flow.py +++ b/tests/components/netatmo/test_config_flow.py @@ -7,7 +7,6 @@ import pytest from homeassistant import config_entries -from homeassistant.components.netatmo import config_flow from homeassistant.components.netatmo.const import ( CONF_NEW_AREA, CONF_WEATHER_AREAS, @@ -35,10 +34,7 @@ async def test_abort_if_existing_entry(hass: HomeAssistant) -> None: """Check flow abort when an entry already exist.""" - MockConfigEntry(domain=DOMAIN).add_to_hass(hass) - - flow = config_flow.NetatmoFlowHandler() - flow.hass = hass + MockConfigEntry(domain=DOMAIN, unique_id=DOMAIN).add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -60,7 +56,18 @@ async def test_abort_if_existing_entry(hass: HomeAssistant) -> None: ), ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "single_instance_allowed" + + +async def test_abort_if_legacy_entry_without_unique_id(hass: HomeAssistant) -> None: + """Check user flow aborts for a legacy entry that has no unique_id yet.""" + MockConfigEntry(domain=DOMAIN, unique_id=None).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" @pytest.mark.usefixtures("current_request_with_host") diff --git a/tests/components/nobo_hub/__init__.py b/tests/components/nobo_hub/__init__.py index 24f63401951b5a..d487b000d04d7a 100644 --- a/tests/components/nobo_hub/__init__.py +++ b/tests/components/nobo_hub/__init__.py @@ -10,3 +10,13 @@ async def fire_hub_update(hass: HomeAssistant, hub: MagicMock) -> None: for call in hub.register_callback.call_args_list: call.args[0](hub) await hass.async_block_till_done() + + +async def fire_hub_connection( + hass: HomeAssistant, hub: MagicMock, connected: bool +) -> None: + """Fire the hub's registered connection-state callbacks and wait for state to settle.""" + hub.connected = connected + for call in hub.register_connection_callback.call_args_list: + call.args[0](hub, connected) + await hass.async_block_till_done() diff --git a/tests/components/nobo_hub/conftest.py b/tests/components/nobo_hub/conftest.py index 7df91c55130ece..974e803740c347 100644 --- a/tests/components/nobo_hub/conftest.py +++ b/tests/components/nobo_hub/conftest.py @@ -54,6 +54,12 @@ def config_entry_options() -> dict[str, Any]: return {} +@pytest.fixture +def hub_connected() -> bool: + """Whether the mocked hub reports itself connected after setup.""" + return True + + @pytest.fixture def mock_config_entry( ip_address: str, @@ -75,6 +81,7 @@ def mock_config_entry( @pytest.fixture def mock_nobo_class( connect_exc: BaseException | None, + hub_connected: bool, ) -> Generator[MagicMock]: """Patch the integration's imported `nobo` class with a populated hub instance.""" with patch("homeassistant.components.nobo_hub.nobo", autospec=True) as mock_cls: @@ -82,6 +89,7 @@ def mock_nobo_class( if connect_exc is not None: hub.connect.side_effect = connect_exc + hub.connected = hub_connected hub.hub_serial = SERIAL hub.hub_info = { "name": "My Eco Hub", diff --git a/tests/components/nobo_hub/test_init.py b/tests/components/nobo_hub/test_init.py index 7c628c6ec5bb4b..880aa49dd74f1a 100644 --- a/tests/components/nobo_hub/test_init.py +++ b/tests/components/nobo_hub/test_init.py @@ -1,5 +1,6 @@ """Tests for the Nobø Ecohub integration setup.""" +import logging from unittest.mock import MagicMock from pynobo import nobo as pynobo_nobo @@ -11,15 +12,23 @@ DOMAIN, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_IP_ADDRESS, CONF_MAC +from homeassistant.const import CONF_IP_ADDRESS, CONF_MAC, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from . import fire_hub_connection from .conftest import SERIAL, STORED_IP from tests.common import MockConfigEntry NEW_IP = "192.168.1.55" +GLOBAL_ENTITY = "select.my_eco_hub_global_override" + + +@pytest.fixture +def platforms(request: pytest.FixtureRequest) -> list[Platform]: + """Default to select; override per-test via indirect parametrize.""" + return getattr(request, "param", [Platform.SELECT]) async def test_setup_uses_stored_ip( @@ -224,3 +233,94 @@ async def test_setup_registers_hub_device_with_mac( assert device.connections == { (dr.CONNECTION_NETWORK_MAC, "7c:83:06:01:11:92"), } + + +@pytest.mark.usefixtures("init_integration") +async def test_entity_available_when_hub_connected(hass: HomeAssistant) -> None: + """Entities are available when the hub reports connected.""" + state = hass.states.get(GLOBAL_ENTITY) + assert state is not None + assert state.state != STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("init_integration") +async def test_entity_unavailable_on_disconnect_and_recovers( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, +) -> None: + """Entities become unavailable on disconnect and recover on reconnect.""" + assert hass.states.get(GLOBAL_ENTITY).state != STATE_UNAVAILABLE + + await fire_hub_connection(hass, mock_nobo_hub, False) + assert hass.states.get(GLOBAL_ENTITY).state == STATE_UNAVAILABLE + + await fire_hub_connection(hass, mock_nobo_hub, True) + assert hass.states.get(GLOBAL_ENTITY).state != STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("init_integration") +async def test_log_on_disconnect_and_reconnect( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Disconnects and reconnects both log at info level.""" + caplog.clear() + await fire_hub_connection(hass, mock_nobo_hub, False) + assert any( + record.levelno == logging.INFO + and "Lost connection to Nobø Ecohub" in record.message + for record in caplog.records + ) + + caplog.clear() + await fire_hub_connection(hass, mock_nobo_hub, True) + assert any( + record.levelno == logging.INFO + and "Reconnected to Nobø Ecohub" in record.message + for record in caplog.records + ) + + +@pytest.mark.usefixtures("init_integration") +async def test_connection_callbacks_deregistered_on_unload( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nobo_hub: MagicMock, +) -> None: + """Every registered connection callback is deregistered on entry unload.""" + registered = mock_nobo_hub.register_connection_callback.call_count + assert registered > 0 + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_nobo_hub.deregister_connection_callback.call_count == registered + + +@pytest.mark.parametrize("platforms", [[Platform.CLIMATE]], indirect=True) +@pytest.mark.usefixtures("init_integration") +async def test_zone_removed_during_disconnect_stays_unavailable_on_reconnect( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, +) -> None: + """A zone removed via the Nobø app while disconnected stays unavailable on reconnect. + + The connection callback fires before the data callback (pynobo Option C). + Without the `available` property's existence check, the connection callback's + `_attr_available = True` would briefly flip the entity to available before the + data callback's _read_state could re-mark it unavailable. + """ + entity = "climate.living_room_living_room" + assert hass.states.get(entity).state != STATE_UNAVAILABLE + + await fire_hub_connection(hass, mock_nobo_hub, False) + assert hass.states.get(entity).state == STATE_UNAVAILABLE + + # Simulate the zone being removed via the Nobø app while disconnected: + # by the time the hub reconnects and _get_initial_data runs, hub.zones + # no longer contains the zone. + mock_nobo_hub.zones = {} + + await fire_hub_connection(hass, mock_nobo_hub, True) + assert hass.states.get(entity).state == STATE_UNAVAILABLE diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index 530210e7f32455..0644a9faa89130 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -13,6 +13,7 @@ from homeassistant.components import conversation, ollama from homeassistant.components.conversation import trace +from homeassistant.components.llm import LLMTools from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -292,7 +293,7 @@ async def test_template_variables( ), ], ) -@patch("homeassistant.components.ollama.entity.llm.AssistAPI._async_get_tools") +@patch("homeassistant.components.llm.async_get_tools", new_callable=AsyncMock) async def test_function_call( mock_get_tools, hass: HomeAssistant, @@ -314,7 +315,7 @@ async def test_function_call( ) mock_tool.async_call.return_value = "Test response" - mock_get_tools.return_value = [mock_tool] + mock_get_tools.return_value = LLMTools(tools=[mock_tool]) def completion_result(*args, messages, **kwargs): for message in messages: @@ -379,7 +380,7 @@ def completion_result(*args, messages, **kwargs): ) -@patch("homeassistant.components.ollama.entity.llm.AssistAPI._async_get_tools") +@patch("homeassistant.components.llm.async_get_tools", new_callable=AsyncMock) async def test_function_exception( mock_get_tools, hass: HomeAssistant, @@ -398,7 +399,7 @@ async def test_function_exception( ) mock_tool.async_call.side_effect = HomeAssistantError("Test tool exception") - mock_get_tools.return_value = [mock_tool] + mock_get_tools.return_value = LLMTools(tools=[mock_tool]) def completion_result(*args, messages, **kwargs): for message in messages: diff --git a/tests/components/overkiz/snapshots/test_number.ambr b/tests/components/overkiz/snapshots/test_number.ambr index 6fcac0d69d779c..80410a24e3d389 100644 --- a/tests/components/overkiz/snapshots/test_number.ambr +++ b/tests/components/overkiz/snapshots/test_number.ambr @@ -484,7 +484,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '85', + 'state': '15', }) # --- # name: test_number_entities_snapshot[cloud_nexity_rail_din_europe.json][number.maple_residence_living_room_air_inlet_my_position-entry] @@ -786,7 +786,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '85', + 'state': '15', }) # --- # name: test_number_entities_snapshot[cloud_nexity_rail_din_europe.json][number.maple_residence_office_shutter_my_position-entry] @@ -846,7 +846,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '85', + 'state': '15', }) # --- # name: test_number_entities_snapshot[cloud_nexity_rail_din_europe.json][number.maple_residence_study_radiator_comfort_room_temperature-entry] @@ -1154,7 +1154,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '86', + 'state': '14', }) # --- # name: test_number_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][number.garage_dining_room_shutter_my_position-entry] @@ -1214,7 +1214,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '69', + 'state': '31', }) # --- # name: test_number_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][number.garage_guest_room_shutter_my_position-entry] @@ -1274,7 +1274,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '84', + 'state': '16', }) # --- # name: test_number_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][number.guest_bedroom_kids_room_shutter_my_position-entry] @@ -1334,7 +1334,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '86', + 'state': '14', }) # --- # name: test_number_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][number.kids_room_kitchen_shutter_my_position-entry] @@ -1394,7 +1394,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '87', + 'state': '13', }) # --- # name: test_number_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][number.kids_room_office_shutter_my_position-entry] @@ -1454,7 +1454,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '72', + 'state': '28', }) # --- # name: test_number_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][number.kids_room_patio_shutter_my_position-entry] @@ -1514,7 +1514,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '81', + 'state': '19', }) # --- # name: test_number_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][number.main_bedroom_bedroom_blinds_my_position-entry] @@ -1574,7 +1574,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '86', + 'state': '14', }) # --- # name: test_number_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][number.office_garden_house_shutter_my_position-entry] @@ -1634,7 +1634,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '86', + 'state': '14', }) # --- # name: test_number_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][number.reading_nook_living_room_shutter_my_position-entry] @@ -1694,7 +1694,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '86', + 'state': '14', }) # --- # name: test_number_entities_snapshot[local_somfy_tahoma_switch_europe.json][number.bathroom_blinds_my_position-entry] @@ -1754,7 +1754,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '0', }) # --- # name: test_number_entities_snapshot[local_somfy_tahoma_switch_europe.json][number.bedroom_blinds_my_position-entry] @@ -1814,7 +1814,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '0', }) # --- # name: test_number_entities_snapshot[local_somfy_tahoma_switch_europe.json][number.dining_room_blinds_my_position-entry] @@ -1874,7 +1874,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '0', }) # --- # name: test_number_entities_snapshot[local_somfy_tahoma_switch_europe.json][number.garage_blinds_my_position-entry] @@ -1934,7 +1934,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '0', }) # --- # name: test_number_entities_snapshot[local_somfy_tahoma_switch_europe.json][number.guest_room_blinds_my_position-entry] @@ -1994,7 +1994,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '0', }) # --- # name: test_number_entities_snapshot[local_somfy_tahoma_switch_europe.json][number.hallway_blinds_my_position-entry] @@ -2054,7 +2054,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '0', }) # --- # name: test_number_entities_snapshot[local_somfy_tahoma_switch_europe.json][number.kitchen_blinds_my_position-entry] @@ -2114,7 +2114,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '0', }) # --- # name: test_number_entities_snapshot[local_somfy_tahoma_switch_europe.json][number.living_room_blinds_my_position-entry] @@ -2174,7 +2174,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '0', }) # --- # name: test_number_entities_snapshot[local_somfy_tahoma_switch_europe.json][number.master_bedroom_blinds_my_position-entry] @@ -2234,7 +2234,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '0', }) # --- # name: test_number_entities_snapshot[local_somfy_tahoma_switch_europe.json][number.nursery_blinds_my_position-entry] @@ -2294,7 +2294,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '0', }) # --- # name: test_number_entities_snapshot[local_somfy_tahoma_switch_europe.json][number.office_blinds_my_position-entry] @@ -2354,7 +2354,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '85', + 'state': '15', }) # --- # name: test_number_entities_snapshot[local_somfy_tahoma_switch_europe.json][number.study_blinds_my_position-entry] @@ -2414,6 +2414,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '0', }) # --- diff --git a/tests/components/overkiz/test_number.py b/tests/components/overkiz/test_number.py index 55ee178ee7631e..8d570920bc1079 100644 --- a/tests/components/overkiz/test_number.py +++ b/tests/components/overkiz/test_number.py @@ -110,6 +110,29 @@ async def test_number_set_value( ) +async def test_number_inverted_memorized_position_set( + hass: HomeAssistant, + setup_overkiz_integration: SetupOverkizIntegration, + mock_client: MockOverkizClient, +) -> None: + """Test that setting a cover's "My position" inverts before sending.""" + await setup_overkiz_integration(fixture=OFFICE_BLINDS_MEMORIZED_POSITION.fixture) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: OFFICE_BLINDS_MEMORIZED_POSITION.entity_id, ATTR_VALUE: 15}, + blocking=True, + ) + + assert_command_call( + mock_client, + device_url=OFFICE_BLINDS_MEMORIZED_POSITION.device_url, + command_name="setMemorized1Position", + parameters=[85], + ) + + async def test_number_dynamic_min_max( hass: HomeAssistant, setup_overkiz_integration: SetupOverkizIntegration, diff --git a/tests/components/portainer/test_init.py b/tests/components/portainer/test_init.py index aa435a522e2fc8..dab13e650cf2ac 100644 --- a/tests/components/portainer/test_init.py +++ b/tests/components/portainer/test_init.py @@ -53,7 +53,7 @@ async def test_setup_exceptions( expected_state: ConfigEntryState, ) -> None: """Test the _async_setup.""" - mock_portainer_client.get_endpoints.side_effect = exception + mock_portainer_client.portainer_system_status.side_effect = exception await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is expected_state diff --git a/tests/components/profiler/test_init.py b/tests/components/profiler/test_init.py index f2d0befe630e44..f450dd4474258b 100644 --- a/tests/components/profiler/test_init.py +++ b/tests/components/profiler/test_init.py @@ -13,7 +13,8 @@ import objgraph import pytest -from homeassistant.components.profiler import ( +from homeassistant.components.profiler.const import DOMAIN +from homeassistant.components.profiler.services import ( _LRU_CACHE_WRAPPER_OBJECT, _SQLALCHEMY_LRU_OBJECT, CONF_SECONDS, @@ -31,7 +32,6 @@ SERVICE_STOP_LOG_OBJECT_SOURCES, SERVICE_STOP_LOG_OBJECTS, ) -from homeassistant.components.profiler.const import DOMAIN from homeassistant.const import CONF_ENABLED, CONF_SCAN_INTERVAL, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError diff --git a/tests/components/remote_calendar/test_calendar.py b/tests/components/remote_calendar/test_calendar.py index 8fa54e50bfba42..cf074cc1d77e3c 100644 --- a/tests/components/remote_calendar/test_calendar.py +++ b/tests/components/remote_calendar/test_calendar.py @@ -565,13 +565,7 @@ async def test_coordinator_refresh_updates_upcoming_event_state( """ ) route = respx.get(CALENDER_URL).mock( - side_effect=[ - Response(status_code=200, text=original_calendar), - # We currently update the calendar twice on startup, tracked - # in issue #148315 - Response(status_code=200, text=original_calendar), - Response(status_code=200, text=updated_calendar), - ] + return_value=Response(status_code=200, text=original_calendar) ) await setup_integration(hass, config_entry) @@ -580,10 +574,10 @@ async def test_coordinator_refresh_updates_upcoming_event_state( assert state.attributes.get("start_time") == "2026-05-18 06:40:00" # Advance clock to trigger the next update interval + route.return_value = Response(status_code=200, text=updated_calendar) async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=1)) await hass.async_block_till_done() state = hass.states.get(TEST_ENTITY) assert state assert state.attributes.get("start_time") == "2026-05-19 08:00:00" - assert route.call_count == 3 diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index df4a23dd27ebb9..78ba927837a26e 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -8,8 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest -from soco import SoCo -from soco.alarms import Alarms +from soco import SoCo, soco_reset from soco.data_structures import ( DidlFavorite, DidlMusicTrack, @@ -167,15 +166,12 @@ async def async_autosetup_sonos(async_setup_sonos): await async_setup_sonos() -def reset_sonos_alarms(alarm_event: SonosMockEvent) -> None: - """Reset the Sonos alarms to a known state.""" - sonos_alarms = Alarms() - sonos_alarms.alarms = {} - sonos_alarms._last_zone_used = None - sonos_alarms._last_alarm_list_version = None - sonos_alarms.last_uid = None - sonos_alarms.last_id = 0 - alarm_event.variables["alarm_list_version"] = "RINCON_test:0" +@pytest.fixture(autouse=True) +def reset_sonos(): + """Reset soco state before and after each test.""" + soco_reset() + yield + soco_reset() @pytest.fixture @@ -186,7 +182,6 @@ def async_setup_sonos( async def _wrapper(): config_entry.add_to_hass(hass) - reset_sonos_alarms(alarm_event) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done(wait_background_tasks=True) await fire_zgs_event() @@ -953,7 +948,6 @@ async def sonos_setup_two_speakers( """Set up home assistant with two Sonos Speakers.""" soco_lr = soco_factory.cache_mock(MockSoCo(), "10.10.10.1", "Living Room") soco_br = soco_factory.cache_mock(MockSoCo(), "10.10.10.2", "Bedroom") - reset_sonos_alarms(alarm_event) await async_setup_component( hass, diff --git a/tests/components/steam_online/__init__.py b/tests/components/steam_online/__init__.py index 51c0d5a2a579ee..2208159f033ee7 100644 --- a/tests/components/steam_online/__init__.py +++ b/tests/components/steam_online/__init__.py @@ -1,6 +1,6 @@ """Tests for Steam integration.""" -from homeassistant.components.steam_online.const import CONF_ACCOUNT, CONF_ACCOUNTS +from homeassistant.components.steam_online.const import CONF_ACCOUNT from homeassistant.const import CONF_API_KEY API_KEY = "abc123" @@ -13,12 +13,3 @@ CONF_API_KEY: API_KEY, CONF_ACCOUNT: ACCOUNT_1, } - -CONF_OPTIONS = {CONF_ACCOUNTS: {ACCOUNT_1: ACCOUNT_NAME_1}} - -CONF_OPTIONS_2 = { - CONF_ACCOUNTS: { - ACCOUNT_1: ACCOUNT_NAME_1, - ACCOUNT_2: ACCOUNT_NAME_2, - } -} diff --git a/tests/components/steam_online/conftest.py b/tests/components/steam_online/conftest.py index a71782eda153e2..44630ada87c4f1 100644 --- a/tests/components/steam_online/conftest.py +++ b/tests/components/steam_online/conftest.py @@ -5,9 +5,10 @@ import pytest -from homeassistant.components.steam_online.const import DOMAIN +from homeassistant.components.steam_online.const import DOMAIN, SUBENTRY_TYPE_FRIEND +from homeassistant.config_entries import ConfigSubentryData -from . import ACCOUNT_1, CONF_DATA, CONF_OPTIONS +from . import ACCOUNT_1, ACCOUNT_2, ACCOUNT_NAME_2, CONF_DATA from tests.common import MockConfigEntry, load_json_object_fixture, patch @@ -18,9 +19,16 @@ def mock_config_entry() -> MockConfigEntry: return MockConfigEntry( domain=DOMAIN, data=CONF_DATA, - options=CONF_OPTIONS, unique_id=ACCOUNT_1, - version=2, + subentries_data=[ + ConfigSubentryData( + data={}, + subentry_type=SUBENTRY_TYPE_FRIEND, + title=ACCOUNT_NAME_2, + unique_id=ACCOUNT_2, + ), + ], + version=3, ) @@ -42,6 +50,11 @@ def mock_steam_api() -> Generator[MagicMock]: "homeassistant.components.steam_online.config_flow.steam.api.interface" ) as mock_client, patch("homeassistant.components.steam_online.config_flow.steam.api.key.set"), + patch( + "homeassistant.components.steam_online.coordinator.steam.api.interface", + new=mock_client, + ), + patch("homeassistant.components.steam_online.coordinator.steam.api.key.set"), patch( "homeassistant.components.steam_online.config_flow.MAX_IDS_TO_REQUEST", 2 ), diff --git a/tests/components/steam_online/fixtures/GetPlayerSummariesSingle.json b/tests/components/steam_online/fixtures/GetPlayerSummariesSingle.json new file mode 100644 index 00000000000000..dae685459f4297 --- /dev/null +++ b/tests/components/steam_online/fixtures/GetPlayerSummariesSingle.json @@ -0,0 +1,22 @@ +{ + "response": { + "players": { + "player": [ + { + "steamid": "12345678912345678", + "communityvisibilitystate": 1, + "profilestate": 1, + "personaname": "testaccount2", + "profileurl": "https://steamcommunity.com/profiles/987654321/", + "avatar": "https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb.jpg", + "avatarmedium": "https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_medium.jpg", + "avatarfull": "https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg", + "avatarhash": "fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb", + "lastlogoff": 1775409487, + "personastate": 2, + "personastateflags": 0 + } + ] + } + } +} diff --git a/tests/components/steam_online/snapshots/test_init.ambr b/tests/components/steam_online/snapshots/test_init.ambr new file mode 100644 index 00000000000000..62d11b8f6b6d6a --- /dev/null +++ b/tests/components/steam_online/snapshots/test_init.ambr @@ -0,0 +1,32 @@ +# serializer version: 1 +# name: test_device_info + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://steamcommunity.com/profiles/123456789/', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'steam_online', + '12345678901234567', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Steam', + 'model': None, + 'model_id': None, + 'name': 'testaccount1', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/steam_online/snapshots/test_sensor.ambr b/tests/components/steam_online/snapshots/test_sensor.ambr index 26e5aa3a1b0bac..23261040871dff 100644 --- a/tests/components/steam_online/snapshots/test_sensor.ambr +++ b/tests/components/steam_online/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_sensors[sensor.steam_testaccount1-entry] +# name: test_sensors[sensor.testaccount1-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -13,7 +13,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.steam_testaccount1', + 'entity_id': 'sensor.testaccount1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -21,12 +21,12 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'testaccount1', + 'object_id_base': None, 'options': dict({ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'testaccount1', + 'original_name': None, 'platform': 'steam_online', 'previous_unique_id': None, 'suggested_object_id': None, @@ -36,11 +36,11 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors[sensor.steam_testaccount1-state] +# name: test_sensors[sensor.testaccount1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg', - : 'Steam testaccount1', + : 'testaccount1', 'game': 'The Witcher: Enhanced Edition', 'game_icon': 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/apps/20900/746d1cd48fb2e57d579b05b6e9eccba95859e549.jpg', 'game_id': '20900', @@ -50,10 +50,63 @@ 'level': 10, }), 'context': , - 'entity_id': 'sensor.steam_testaccount1', + 'entity_id': 'sensor.testaccount1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'online', }) # --- +# name: test_sensors[sensor.testaccount2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testaccount2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'steam_online', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '12345678912345678_account', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testaccount2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg', + : 'testaccount2', + 'last_online': datetime.datetime(2026, 4, 5, 10, 18, 7, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), + 'level': 10, + }), + 'context': , + 'entity_id': 'sensor.testaccount2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'busy', + }) +# --- diff --git a/tests/components/steam_online/test_config_flow.py b/tests/components/steam_online/test_config_flow.py index 9c22c9ab09ef32..950c0f493e27dc 100644 --- a/tests/components/steam_online/test_config_flow.py +++ b/tests/components/steam_online/test_config_flow.py @@ -6,23 +6,19 @@ import pytest import steam.api -from homeassistant.components.steam_online.const import CONF_ACCOUNTS, DOMAIN -from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_API_KEY +from homeassistant.components.steam_online.const import ( + CONF_ACCOUNT, + DOMAIN, + SUBENTRY_TYPE_FRIEND, +) +from homeassistant.config_entries import SOURCE_USER, ConfigEntryState, ConfigSubentry +from homeassistant.const import CONF_API_KEY, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import entity_registry as er - -from . import ( - ACCOUNT_1, - ACCOUNT_2, - ACCOUNT_NAME_1, - CONF_DATA, - CONF_OPTIONS, - CONF_OPTIONS_2, -) -from tests.common import MockConfigEntry +from . import ACCOUNT_1, ACCOUNT_2, ACCOUNT_NAME_1, ACCOUNT_NAME_2, API_KEY, CONF_DATA + +from tests.common import MockConfigEntry, async_load_json_object_fixture @pytest.mark.usefixtures("steam_api") @@ -46,7 +42,6 @@ async def test_flow_user( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == ACCOUNT_NAME_1 assert result["data"] == CONF_DATA - assert result["options"] == CONF_OPTIONS assert result["result"].unique_id == ACCOUNT_1 assert len(mock_setup_entry.mock_calls) == 1 @@ -94,7 +89,6 @@ async def test_flow_user_errors( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == ACCOUNT_NAME_1 assert result["data"] == CONF_DATA - assert result["options"] == CONF_OPTIONS assert result["result"].unique_id == ACCOUNT_1 assert len(mock_setup_entry.mock_calls) == 1 @@ -120,6 +114,30 @@ async def test_flow_user_already_configured( assert result["reason"] == "already_configured" +@pytest.mark.usefixtures("steam_api") +async def test_flow_user_already_configured_as_subentry( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test user initialized flow with duplicate account.""" + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: API_KEY, + CONF_ACCOUNT: ACCOUNT_2, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured_as_subentry" + + @pytest.mark.usefixtures("steam_api") async def test_flow_reauth( hass: HomeAssistant, @@ -188,103 +206,297 @@ async def test_flow_reauth_errors( assert len(hass.config_entries.async_entries()) == 1 -@pytest.mark.usefixtures("steam_api") -async def test_options_flow( - hass: HomeAssistant, - config_entry: MockConfigEntry, -) -> None: - """Test updating options.""" - config_entry.add_to_hass(hass) +async def test_add_friend_flow(hass: HomeAssistant, steam_api: MagicMock) -> None: + """Test add friend subentry flow.""" + steam_api.return_value.GetPlayerSummaries.return_value = ( + await async_load_json_object_fixture( + hass, "GetPlayerSummariesSingle.json", DOMAIN + ) + ) + config_entry = MockConfigEntry( + domain=DOMAIN, + title=ACCOUNT_NAME_1, + data=CONF_DATA, + unique_id=ACCOUNT_1, + version=3, + ) + config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - result = await hass.config_entries.options.async_init(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, SUBENTRY_TYPE_FRIEND), + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" + assert result["step_id"] == "user" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.subentries.async_configure( result["flow_id"], - user_input={CONF_ACCOUNTS: [ACCOUNT_1, ACCOUNT_2]}, + user_input={CONF_ACCOUNT: ACCOUNT_2}, ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == CONF_OPTIONS_2 + subentry_id = list(config_entry.subentries)[0] + assert config_entry.subentries == { + subentry_id: ConfigSubentry( + data={}, + subentry_id=subentry_id, + subentry_type=SUBENTRY_TYPE_FRIEND, + title=ACCOUNT_NAME_2, + unique_id=ACCOUNT_2, + ) + } @pytest.mark.usefixtures("steam_api") -async def test_options_flow_deselect( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - config_entry: MockConfigEntry, +async def test_add_friend_flow_already_configured( + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: - """Test deselecting user.""" + """Test add friend subentry flow aborts if friend is already configured as subentry.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, SUBENTRY_TYPE_FRIEND), + context={"source": SOURCE_USER}, + data={CONF_ACCOUNT: ACCOUNT_2}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("steam_api") +async def test_add_friend_flow_already_configured_as_entry(hass: HomeAssistant) -> None: + """Test add friend subentry flow aborts if friend is already configured as config entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title=ACCOUNT_NAME_1, + data=CONF_DATA, + unique_id=ACCOUNT_1, + version=3, + ) + MockConfigEntry( + domain=DOMAIN, + title=ACCOUNT_NAME_2, + data=CONF_DATA, + unique_id=ACCOUNT_2, + version=3, + ).add_to_hass(hass) + config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - result = await hass.config_entries.options.async_init(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, SUBENTRY_TYPE_FRIEND), + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" + assert result["step_id"] == "user" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.subentries.async_configure( result["flow_id"], - user_input={CONF_ACCOUNTS: []}, + user_input={CONF_ACCOUNT: ACCOUNT_2}, ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == {CONF_ACCOUNTS: {}} - assert len(entity_registry.entities) == 0 + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured_as_entry" -async def test_options_flow_timeout( +@pytest.mark.parametrize( + ("side_effect", "reason", "description_placeholders"), + [ + ( + steam.api.HTTPError("Server connection failed: Unauthorized (401)"), + "friendlist_private", + { + CONF_NAME: ACCOUNT_NAME_1, + "privacy_settings_url": "https://steamcommunity.com/profiles/123456789/edit/settings", + }, + ), + ( + steam.api.HTTPError, + "cannot_connect", + None, + ), + ( + steam.api.HTTPTimeoutError, + "timeout_connect", + None, + ), + ( + ValueError, + "unknown", + None, + ), + ], +) +async def test_add_friend_flow_abort_errors( hass: HomeAssistant, - config_entry: MockConfigEntry, steam_api: MagicMock, + config_entry: MockConfigEntry, + side_effect: type[Exception] | Exception, + reason: str, + description_placeholders: dict[str, str] | None, ) -> None: - """Test updating options timeout getting friends list.""" + """Test add friend subentry flow aborts on errors.""" + + steam_api.return_value.GetFriendList.side_effect = side_effect + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) - steam_api.return_value.GetFriendList.side_effect = steam.api.HTTPTimeoutError - result = await hass.config_entries.options.async_init(config_entry.entry_id) + await hass.async_block_till_done() - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" + assert config_entry.state is ConfigEntryState.LOADED - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={CONF_ACCOUNTS: [ACCOUNT_1]}, + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, SUBENTRY_TYPE_FRIEND), + context={"source": SOURCE_USER}, ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == CONF_OPTIONS + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason + assert result["description_placeholders"] == description_placeholders -async def test_options_flow_unauthorized( +async def test_add_friend_flow_abort_no_more_friends( hass: HomeAssistant, + steam_api: MagicMock, config_entry: MockConfigEntry, +) -> None: + """Test add friend subentry flow aborts when no more friends left to add.""" + + steam_api.return_value.GetPlayerSummaries.return_value = ( + await async_load_json_object_fixture( + hass, "GetPlayerSummariesSingle.json", DOMAIN + ) + ) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, SUBENTRY_TYPE_FRIEND), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_more_friends" + + +@pytest.mark.usefixtures("steam_api") +async def test_add_friend_flow_config_entry_not_loaded( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test add friend subentry flow.""" + config_entry.add_to_hass(hass) + + assert config_entry.state is ConfigEntryState.NOT_LOADED + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, SUBENTRY_TYPE_FRIEND), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "config_entry_not_loaded" + + +@pytest.mark.parametrize( + ("side_effect", "error_msg"), + [ + (steam.api.HTTPTimeoutError, "timeout_connect"), + (steam.api.HTTPError, "cannot_connect"), + (ValueError, "unknown"), + ], +) +async def test_add_friend_errors( + hass: HomeAssistant, steam_api: MagicMock, + side_effect: type[Exception], + error_msg: str, ) -> None: - """Test updating options when user's friends list is not public.""" + """Test add friend subentry flow with recoverable errors.""" + + player_summaries = await async_load_json_object_fixture( + hass, "GetPlayerSummariesSingle.json", DOMAIN + ) + steam_api.return_value.GetPlayerSummaries.return_value = player_summaries + + config_entry = MockConfigEntry( + domain=DOMAIN, + title=ACCOUNT_NAME_1, + data=CONF_DATA, + unique_id=ACCOUNT_1, + version=3, + ) + config_entry.add_to_hass(hass) - steam_api.return_value.GetFriendList.side_effect = steam.api.HTTPError - result = await hass.config_entries.options.async_init(config_entry.entry_id) + await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, SUBENTRY_TYPE_FRIEND), + context={"source": SOURCE_USER}, + ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" + assert result["step_id"] == "user" - result = await hass.config_entries.options.async_configure( + steam_api.return_value.GetPlayerSummaries.side_effect = [ + side_effect, + player_summaries, + ] + + result = await hass.config_entries.subentries.async_configure( result["flow_id"], - user_input={CONF_ACCOUNTS: [ACCOUNT_1]}, + user_input={CONF_ACCOUNT: ACCOUNT_2}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_msg} + + steam_api.return_value.GetPlayerSummaries.side_effect = None + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_ACCOUNT: ACCOUNT_2}, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == CONF_OPTIONS + subentry_id = list(config_entry.subentries)[0] + assert config_entry.subentries == { + subentry_id: ConfigSubentry( + data={}, + subentry_id=subentry_id, + subentry_type=SUBENTRY_TYPE_FRIEND, + title=ACCOUNT_NAME_2, + unique_id=ACCOUNT_2, + ) + } @pytest.mark.usefixtures("steam_api") diff --git a/tests/components/steam_online/test_init.py b/tests/components/steam_online/test_init.py index 89ea46f6a00b34..61339d066f9757 100644 --- a/tests/components/steam_online/test_init.py +++ b/tests/components/steam_online/test_init.py @@ -4,14 +4,19 @@ import pytest import steam.api +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.steam_online.const import DEFAULT_NAME, DOMAIN +from homeassistant.components.steam_online.const import ( + CONF_ACCOUNTS, + DOMAIN, + SUBENTRY_TYPE_FRIEND, +) from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import ACCOUNT_1, ACCOUNT_NAME_1, CONF_DATA, CONF_OPTIONS +from . import ACCOUNT_1, ACCOUNT_2, ACCOUNT_NAME_1, ACCOUNT_NAME_2, CONF_DATA from tests.common import MockConfigEntry @@ -97,6 +102,7 @@ async def test_device_info( hass: HomeAssistant, device_registry: dr.DeviceRegistry, config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test device info.""" config_entry.add_to_hass(hass) @@ -104,28 +110,26 @@ async def test_device_info( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert ( - device := device_registry.async_get_device( - identifiers={(DOMAIN, config_entry.entry_id)} - ) + device_registry.async_get_device(identifiers={(DOMAIN, ACCOUNT_1)}) == snapshot ) - assert device.configuration_url == "https://store.steampowered.com" - assert device.entry_type == dr.DeviceEntryType.SERVICE - assert device.identifiers == {(DOMAIN, config_entry.entry_id)} - assert device.manufacturer == DEFAULT_NAME - assert device.name == DEFAULT_NAME - @pytest.mark.usefixtures("steam_api") async def test_migrate_entry( hass: HomeAssistant, entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, ) -> None: """Test entry migration.""" config_entry = MockConfigEntry( domain=DOMAIN, data=CONF_DATA, - options=CONF_OPTIONS, + options={ + CONF_ACCOUNTS: { + ACCOUNT_1: ACCOUNT_NAME_1, + ACCOUNT_2: ACCOUNT_NAME_2, + } + }, unique_id=ACCOUNT_1, version=1, ) @@ -142,12 +146,30 @@ async def test_migrate_entry( original_name=ACCOUNT_NAME_1, ) + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, config_entry.entry_id)}, + ) + assert sensor.unique_id == f"sensor.steam_{ACCOUNT_1}" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert config_entry.version == 2 + assert config_entry.version == 3 assert (sensor := entity_registry.async_get(sensor.entity_id)) assert sensor.unique_id == f"{ACCOUNT_1}_account" + + assert (device := device_registry.async_get(device.id)) + assert device.identifiers == {(DOMAIN, ACCOUNT_1)} + + assert len(config_entry.subentries) == 1 + subentries = list(config_entry.subentries.values()) + assert subentries[0].unique_id == ACCOUNT_2 + assert subentries[0].title == ACCOUNT_NAME_2 + assert subentries[0].subentry_type == SUBENTRY_TYPE_FRIEND + + assert config_entry.options == {} + + assert device_registry.async_get_device(identifiers={(DOMAIN, ACCOUNT_2)}) diff --git a/tests/components/tankerkoenig/conftest.py b/tests/components/tankerkoenig/conftest.py index 1517c3d2060485..9966d91dc620e7 100644 --- a/tests/components/tankerkoenig/conftest.py +++ b/tests/components/tankerkoenig/conftest.py @@ -5,7 +5,7 @@ import pytest -from homeassistant.components.tankerkoenig import DOMAIN +from homeassistant.components.tankerkoenig.const import DOMAIN from homeassistant.const import CONF_SHOW_ON_MAP from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/tankerkoenig/test_sensor.py b/tests/components/tankerkoenig/test_sensor.py index 00b507f0015b87..b6201034bf3120 100644 --- a/tests/components/tankerkoenig/test_sensor.py +++ b/tests/components/tankerkoenig/test_sensor.py @@ -5,7 +5,7 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.tankerkoenig import DOMAIN +from homeassistant.components.tankerkoenig.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py index 0813e96bc461e8..9fe566ed877f9e 100644 --- a/tests/components/template/test_cover.py +++ b/tests/components/template/test_cover.py @@ -35,6 +35,7 @@ ConfigurationStyle, TemplatePlatformSetup, assert_action, + assert_state_and_attributes, async_get_flow_preview_state, async_trigger, make_test_action, @@ -42,6 +43,8 @@ setup_and_test_nested_unique_id, setup_and_test_unique_id, setup_entity, + setup_mock_template_entity_restore_state, + setup_restore_template_entity, ) from tests.common import MockConfigEntry @@ -49,6 +52,7 @@ TEST_STATE_ENTITY_ID = "sensor.test_state" TEST_POSITION_ENTITY_ID = "sensor.test_position" +TEST_TILT_POSITION_ENTITY_ID = "sensor.test_tilt_position" TEST_AVAILABILITY_ENTITY = "binary_sensor.availability" TEST_COVER = TemplatePlatformSetup( @@ -57,6 +61,7 @@ make_test_trigger( TEST_STATE_ENTITY_ID, TEST_POSITION_ENTITY_ID, + TEST_TILT_POSITION_ENTITY_ID, TEST_AVAILABILITY_ENTITY, ), ) @@ -1102,3 +1107,167 @@ async def test_flow_preview( ) assert state["state"] == CoverState.OPEN + + +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + "config", + [ + { + "position": "{{ states('sensor.test_position') | float(None) }}", + "set_cover_position": [], + "tilt": "{{ states('sensor.test_tilt_position') | float(None) }}", + "set_cover_tilt_position": [], + }, + ], +) +@pytest.mark.parametrize( + ( + "saved_state", + "saved_extra_data", + "initial_state", + "initial_attributes", + "final_state", + ), + [ + ( + CoverState.OPEN, + { + "current_cover_position": 10, + "current_cover_tilt_position": 10, + "is_opening": False, + "is_closing": False, + }, + CoverState.OPEN, + { + "current_position": 10, + "current_tilt_position": 10, + }, + CoverState.OPEN, + ), + ( + CoverState.OPEN, + { + "current_cover_position": 10, + "current_cover_tilt_position": 10, + "is_opening": True, + "is_closing": False, + }, + CoverState.OPENING, + { + "current_position": 10, + "current_tilt_position": 10, + }, + CoverState.OPENING, + ), + ( + CoverState.OPEN, + { + "current_cover_position": 10, + "current_cover_tilt_position": 10, + "is_opening": False, + "is_closing": True, + }, + CoverState.CLOSING, + { + "current_position": 10, + "current_tilt_position": 10, + }, + CoverState.CLOSING, + ), + ( + CoverState.OPEN, + { + "current_cover_position": 0, + "current_cover_tilt_position": 10, + "is_opening": False, + "is_closing": False, + }, + CoverState.CLOSED, + { + "current_position": 0, + "current_tilt_position": 10, + }, + CoverState.OPEN, + ), + ( + STATE_UNAVAILABLE, + { + "current_cover_position": 0, + "current_cover_tilt_position": 10, + "is_opening": False, + "is_closing": False, + }, + STATE_UNKNOWN, + { + "current_position": None, + "current_tilt_position": None, + }, + CoverState.OPEN, + ), + ( + STATE_UNKNOWN, + { + "current_cover_position": 0, + "current_cover_tilt_position": 10, + "is_opening": False, + "is_closing": False, + }, + STATE_UNKNOWN, + { + "current_position": None, + "current_tilt_position": None, + }, + CoverState.OPEN, + ), + ], +) +async def test_restore_state( + hass: HomeAssistant, + config: ConfigType, + style: ConfigurationStyle, + saved_state: CoverState | str, + saved_extra_data: dict | None, + initial_state: CoverState | str, + initial_attributes: ConfigType, + final_state: CoverState | str, +) -> None: + """Test restoring trigger template weather.""" + + restored_attributes = { # These should be ignored + "current_position": 5, + "current_tilt_position": 5, + } + + setup_mock_template_entity_restore_state( + hass, + TEST_COVER, + saved_state, + saved_extra_data=saved_extra_data, + saved_attributes=restored_attributes, + ) + + await setup_restore_template_entity( + hass, + TEST_COVER, + style, + config, + "states('sensor.test_position') | float(0) > 50", + ) + + state = assert_state_and_attributes( + hass, + TEST_COVER, + initial_state, + initial_attributes, + ) + + await async_trigger(hass, "sensor.test_position", "75") + await async_trigger(hass, "sensor.test_tilt_position", "75") + + state = hass.states.get(TEST_COVER.entity_id) + assert state.state == final_state + assert state.attributes["current_position"] == 75 + assert state.attributes["current_tilt_position"] == 75 diff --git a/tests/components/template/test_device_tracker.py b/tests/components/template/test_device_tracker.py index b47d249def9970..59eab709bf5424 100644 --- a/tests/components/template/test_device_tracker.py +++ b/tests/components/template/test_device_tracker.py @@ -21,12 +21,15 @@ from .conftest import ( ConfigurationStyle, TemplatePlatformSetup, + assert_state_and_attributes, async_get_flow_preview_state, async_trigger, make_test_trigger, setup_and_test_nested_unique_id, setup_and_test_unique_id, setup_entity, + setup_mock_template_entity_restore_state, + setup_restore_template_entity, ) from tests.common import MockConfigEntry, async_setup_component @@ -35,6 +38,7 @@ TEST_STATE_ENTITY_ID = "sensor.test_state" TEST_LATITUDE_ENTITY_ID = "sensor.test_latitude" TEST_LONGITUDE_ENTITY_ID = "sensor.test_longitude" +TEST_LOCATION_ACCURACY_ENTITY_ID = "sensor.test_location_accuracy" TEST_AVAILABILITY_ENTITY_ID = "binary_sensor.availability" TEST_TRACKER = TemplatePlatformSetup( device_tracker.DOMAIN, @@ -43,6 +47,7 @@ TEST_AVAILABILITY_ENTITY_ID, TEST_LATITUDE_ENTITY_ID, TEST_LONGITUDE_ENTITY_ID, + TEST_LOCATION_ACCURACY_ENTITY_ID, TEST_STATE_ENTITY_ID, ), ) @@ -623,3 +628,146 @@ async def test_flow_preview( assert state["state"] == STATE_NOT_HOME assert state["attributes"]["latitude"] == 10.0 assert state["attributes"]["longitude"] == 40.0 + + +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + "config", + [ + { + "in_zones": "{{ state_attr('sensor.test_state', 'in_zones') }}", + "latitude": "{{ states('sensor.test_latitude') | float(None) }}", + "longitude": "{{ states('sensor.test_longitude') | float(None) }}", + "location_accuracy": "{{ states('sensor.test_location_accuracy') | float(None) }}", + }, + ], +) +@pytest.mark.parametrize( + ( + "saved_state", + "saved_extra_data", + "initial_state", + "initial_attributes", + ), + [ + ( + STATE_HOME, + { + "in_zones": ["zone.home"], + "latitude": 32.87336, + "longitude": 117.228743, + "location_accuracy": 5.0, + }, + STATE_HOME, + { + "in_zones": ["zone.home"], + "latitude": 32.87336, + "longitude": 117.228743, + "gps_accuracy": 5.0, + }, + ), + ( + STATE_NOT_HOME, + { + "in_zones": [], + "latitude": 15.0, + "longitude": 15.0, + "location_accuracy": 10.0, + }, + STATE_NOT_HOME, + { + "in_zones": [], + "latitude": 15.0, + "longitude": 15.0, + "gps_accuracy": 10.0, + }, + ), + ( + STATE_UNAVAILABLE, + { + "in_zones": [], + "latitude": 15.0, + "longitude": 15.0, + "location_accuracy": 10.0, + }, + STATE_UNKNOWN, + { + "in_zones": [], + "latitude": None, + "longitude": None, + "gps_accuracy": None, + }, + ), + ( + STATE_UNKNOWN, + { + "in_zones": [], + "latitude": 15.0, + "longitude": 15.0, + "location_accuracy": 10.0, + }, + STATE_UNKNOWN, + { + "in_zones": [], + "latitude": None, + "longitude": None, + "gps_accuracy": None, + }, + ), + ], +) +async def test_restore_state( + hass: HomeAssistant, + config: ConfigType, + style: ConfigurationStyle, + saved_state: str, + saved_extra_data: dict | None, + initial_state: str, + initial_attributes: ConfigType, +) -> None: + """Test restoring trigger template device tracker.""" + + restored_attributes = { # These should be ignored + "latitude": 5, + "longitude": 5, + "location_accuracy": 2.0, + } + + setup_mock_template_entity_restore_state( + hass, + TEST_TRACKER, + saved_state, + saved_extra_data=saved_extra_data, + saved_attributes=restored_attributes, + ) + + await setup_restore_template_entity( + hass, + TEST_TRACKER, + style, + config, + "states('sensor.test_latitude') | float(0) > 19.9", + ) + + state = assert_state_and_attributes( + hass, + TEST_TRACKER, + initial_state, + initial_attributes, + ) + + await async_trigger( + hass, "sensor.test_state", "anything", {"in_zones": ["zone.home"]} + ) + await async_trigger(hass, "sensor.test_latitude", "32.88") + await async_trigger(hass, "sensor.test_longitude", "117.24") + await async_trigger(hass, "sensor.test_location_accuracy", "20") + + state = hass.states.get(TEST_TRACKER.entity_id) + assert state.state == STATE_HOME + assert state.attributes["in_zones"] == ["zone.home"] + assert state.attributes["latitude"] == 32.88 + assert state.attributes["longitude"] == 117.24 + assert state.attributes["gps_accuracy"] == 20.0 diff --git a/tests/components/template/test_fan.py b/tests/components/template/test_fan.py index fc5ab9b78ee801..1c2ad479f9bb6d 100644 --- a/tests/components/template/test_fan.py +++ b/tests/components/template/test_fan.py @@ -26,6 +26,7 @@ ConfigurationStyle, TemplatePlatformSetup, assert_action, + assert_state_and_attributes, async_get_flow_preview_state, async_trigger, make_test_action, @@ -33,6 +34,8 @@ setup_and_test_nested_unique_id, setup_and_test_unique_id, setup_entity, + setup_mock_template_entity_restore_state, + setup_restore_template_entity, ) from tests.common import MockConfigEntry @@ -47,7 +50,7 @@ fan.DOMAIN, "test_fan", make_test_trigger( - TEST_INPUT_BOOLEAN, TEST_STATE_ENTITY_ID, TEST_AVAILABILITY_ENTITY + TEST_AVAILABILITY_ENTITY, TEST_INPUT_BOOLEAN, TEST_STATE_ENTITY_ID ), ) @@ -1366,3 +1369,230 @@ async def test_flow_preview( ) assert state["state"] == STATE_ON + + +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + "config", + [ + { + "state": "{{ state_attr('sensor.test_sensor', 'is_on') }}", + "turn_on": [], + "turn_off": [], + "percentage": "{{ state_attr('sensor.test_sensor', 'percentage') }}", + "set_percentage": [], + "preset_mode": "{{ state_attr('sensor.test_sensor', 'preset_mode') }}", + "set_preset_mode": [], + "preset_modes": ["off", "auto", "low", "medium", "high"], + "oscillating": "{{ state_attr('sensor.test_sensor', 'oscillating') }}", + "set_oscillating": [], + "direction": "{{ state_attr('sensor.test_sensor', 'direction') }}", + "set_direction": [], + }, + ], +) +@pytest.mark.parametrize( + ( + "saved_state", + "saved_extra_data", + "initial_state", + "initial_attributes", + ), + [ + ( + STATE_ON, + { + "is_on": True, + "percentage": 10, + "preset_mode": "auto", + "oscillating": True, + "direction": DIRECTION_FORWARD, + }, + STATE_ON, + { + "percentage": 10, + "preset_mode": "auto", + "oscillating": True, + "direction": DIRECTION_FORWARD, + }, + ), + ( + STATE_OFF, + { + "is_on": False, + "percentage": 0, + "preset_mode": "off", + "oscillating": False, + "direction": DIRECTION_FORWARD, + }, + STATE_OFF, + { + "percentage": 0, + "preset_mode": "off", + "oscillating": False, + "direction": DIRECTION_FORWARD, + }, + ), + ( + STATE_UNAVAILABLE, + { + "is_on": True, + "percentage": 0, + "preset_mode": "auto", + "oscillating": True, + "direction": DIRECTION_FORWARD, + }, + STATE_UNKNOWN, + { + "percentage": None, + "preset_mode": None, + "oscillating": None, + "direction": None, + }, + ), + ( + STATE_UNKNOWN, + { + "is_on": False, + "percentage": 0, + "preset_mode": "off", + "oscillating": False, + "direction": DIRECTION_FORWARD, + }, + STATE_UNKNOWN, + { + "percentage": None, + "preset_mode": None, + "oscillating": None, + "direction": None, + }, + ), + ( + STATE_ON, + { + "is_on": "True", + }, + STATE_UNKNOWN, + { + "percentage": None, + "preset_mode": None, + "oscillating": None, + "direction": None, + }, + ), + ( + STATE_ON, + { + "percentage": "0", + }, + STATE_UNKNOWN, + { + "percentage": None, + "preset_mode": None, + "oscillating": None, + "direction": None, + }, + ), + ( + STATE_ON, + { + "oscillating": "True", + }, + STATE_UNKNOWN, + { + "percentage": None, + "preset_mode": None, + "oscillating": None, + "direction": None, + }, + ), + ( + STATE_ON, + { + "preset_mode": 75, + }, + STATE_UNKNOWN, + { + "percentage": None, + "preset_mode": None, + "oscillating": None, + "direction": None, + }, + ), + ( + STATE_ON, + { + "direction": 75, + }, + STATE_UNKNOWN, + { + "percentage": None, + "preset_mode": None, + "oscillating": None, + "direction": None, + }, + ), + ], +) +async def test_restore_state( + hass: HomeAssistant, + config: ConfigType, + style: ConfigurationStyle, + saved_state: str, + saved_extra_data: dict | None, + initial_state: str, + initial_attributes: ConfigType, +) -> None: + """Test restoring template fan.""" + + restored_attributes = { # These should be ignored + "percentage": 45, + "preset_mode": "high", + "oscillating": True, + "direction": DIRECTION_REVERSE, + } + + setup_mock_template_entity_restore_state( + hass, + TEST_FAN, + saved_state, + saved_extra_data=saved_extra_data, + saved_attributes=restored_attributes, + ) + + await setup_restore_template_entity( + hass, + TEST_FAN, + style, + config, + f"states('{TEST_STATE_ENTITY_ID}') | float(0) > 10", + ) + + state = assert_state_and_attributes( + hass, + TEST_FAN, + initial_state, + initial_attributes, + ) + + await async_trigger( + hass, + TEST_STATE_ENTITY_ID, + "11", + { + "is_on": True, + "percentage": 55, + "preset_mode": "low", + "oscillating": True, + "direction": DIRECTION_REVERSE, + }, + ) + + state = hass.states.get(TEST_FAN.entity_id) + assert state.state == STATE_ON + assert state.attributes["percentage"] == 55 + assert state.attributes["preset_mode"] == "low" + assert state.attributes["oscillating"] is True + assert state.attributes["direction"] == DIRECTION_REVERSE diff --git a/tests/components/tesla_wall_connector/test_init.py b/tests/components/tesla_wall_connector/test_init.py index fbb3abc1746720..0393bf372473cd 100644 --- a/tests/components/tesla_wall_connector/test_init.py +++ b/tests/components/tesla_wall_connector/test_init.py @@ -2,13 +2,21 @@ from tesla_wall_connector.exceptions import WallConnectorConnectionError +from homeassistant.components.tesla_wall_connector.const import ( + DOMAIN, + WALLCONNECTOR_DEVICE_MANUFACTURER, + WALLCONNECTOR_DEVICE_MODEL, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from .conftest import create_wall_connector_entry, get_lifetime_mock, get_vitals_mock -async def test_init_success(hass: HomeAssistant) -> None: +async def test_init_success( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test setup and that we get the device info, including firmware version.""" entry = await create_wall_connector_entry( @@ -16,6 +24,13 @@ async def test_init_success(hass: HomeAssistant) -> None: ) assert entry.state is ConfigEntryState.LOADED + device = device_registry.async_get_device(identifiers={(DOMAIN, "abc123")}) + assert device + assert device.manufacturer == WALLCONNECTOR_DEVICE_MANUFACTURER + assert device.model == WALLCONNECTOR_DEVICE_MODEL + assert device.model_id == "part_123" + assert device.serial_number == "abc123" + assert device.sw_version == "1.2.3" async def test_init_while_offline(hass: HomeAssistant) -> None: diff --git a/tests/components/teslemetry/test_init.py b/tests/components/teslemetry/test_init.py index 947998c1db108b..3598dc5da597bb 100644 --- a/tests/components/teslemetry/test_init.py +++ b/tests/components/teslemetry/test_init.py @@ -13,6 +13,7 @@ InsufficientCredits, InvalidResponse, InvalidToken, + LoginRequired, RateLimited, SubscriptionRequired, TeslaFleetError, @@ -61,6 +62,7 @@ ERRORS = [ (InvalidToken, ConfigEntryState.SETUP_ERROR), + (LoginRequired, ConfigEntryState.SETUP_ERROR), (SubscriptionRequired, ConfigEntryState.SETUP_ERROR), (TeslaFleetError, ConfigEntryState.SETUP_RETRY), ] diff --git a/tests/components/unifiprotect/test_event.py b/tests/components/unifiprotect/test_event.py index a21771f847bef4..d22fcbc6a65227 100644 --- a/tests/components/unifiprotect/test_event.py +++ b/tests/components/unifiprotect/test_event.py @@ -244,11 +244,13 @@ def _capture_event(event: HAEvent) -> None: await hass.async_block_till_done() assert len(events) == 1 - # The camera is resolved by device_id, not by the public device_mac, so a - # missing device_mac must still dispatch. + # Subscriptions are keyed by device_id alone: an event still dispatches when + # it carries no device_mac and the device is absent from the private + # bootstrap, proving the public event path does not depend on it. + ufp.api.bootstrap.id_lookup.pop(doorbell.id, None) ufp.events_msg( ProtectEvent( - id="test_package_event_fallback", + id="test_package_event_no_private_device", type=EventType.SMART_DETECT, channel=ProtectEventChannel.DETECTION, device_id=doorbell.id, @@ -262,10 +264,10 @@ def _capture_event(event: HAEvent) -> None: await hass.async_block_till_done() assert len(events) == 2 assert events[1].data["new_state"].attributes[ATTR_EVENT_ID] == ( - "test_package_event_fallback" + "test_package_event_no_private_device" ) - # An event for a device absent from the private bootstrap is dropped. + # An event for a device without a matching subscription is dropped. ufp.events_msg( ProtectEvent( id="test_package_event_unknown", diff --git a/tests/components/unifiprotect/test_select.py b/tests/components/unifiprotect/test_select.py index c3a67f49bb96a8..2173693e3ec206 100644 --- a/tests/components/unifiprotect/test_select.py +++ b/tests/components/unifiprotect/test_select.py @@ -8,6 +8,7 @@ NVR, ArmProfile, Camera, + DeviceState, DoorbellMessageType, IRLEDMode, LCDMessage, @@ -35,7 +36,13 @@ PTZ_PATROL_STOP, VIEWER_SELECTS, ) -from homeassistant.const import ATTR_ATTRIBUTION, ATTR_ENTITY_ID, ATTR_OPTION, Platform +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_ENTITY_ID, + ATTR_OPTION, + STATE_UNAVAILABLE, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -47,7 +54,10 @@ assert_entity_counts, ids_from_device_description, init_entry, + make_public_camera, + public_device_ws_message, remove_entities, + setup_public_camera, ) @@ -159,6 +169,7 @@ async def test_select_setup_camera_all( ) -> None: """Test select entity setup for camera devices (all features).""" + setup_public_camera(ufp) await init_entry(hass, ufp, [doorbell]) assert_entity_counts(hass, Platform.SELECT, 5, 5) @@ -185,6 +196,67 @@ async def test_select_setup_camera_all( assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION +async def test_select_camera_hdr_mode_public_update( + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera +) -> None: + """Test the HDR mode select reads updates from the public devices WS.""" + + setup_public_camera(ufp) + await init_entry(hass, ufp, [doorbell]) + + description = next(d for d in CAMERA_SELECTS if d.key == "hdr_mode") + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, doorbell, description + ) + + state = hass.states.get(entity_id) + assert state + assert state.state == "off" + + public = make_public_camera(doorbell, hdr_type=PublicHdrMode.AUTO) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == "auto" + + +async def test_select_camera_hdr_mode_unavailable_without_public( + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera +) -> None: + """The migrated HDR mode select is unavailable without a public object.""" + + await init_entry(hass, ufp, [doorbell]) + + description = next(d for d in CAMERA_SELECTS if d.key == "hdr_mode") + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, doorbell, description + ) + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + +async def test_select_camera_hdr_mode_unavailable_on_public_disconnect( + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera +) -> None: + """HDR mode availability follows the public object's connection state.""" + + setup_public_camera(ufp) + await init_entry(hass, ufp, [doorbell]) + + description = next(d for d in CAMERA_SELECTS if d.key == "hdr_mode") + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, doorbell, description + ) + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + public = make_public_camera(doorbell, state=DeviceState.DISCONNECTED) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + async def test_select_setup_camera_none( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -193,6 +265,7 @@ async def test_select_setup_camera_none( ) -> None: """Test select entity setup for camera devices (no features).""" + setup_public_camera(ufp) await init_entry(hass, ufp, [camera]) assert_entity_counts(hass, Platform.SELECT, 2, 2) @@ -559,6 +632,7 @@ async def test_select_set_option_camera_hdr_mode( ) -> None: """Test HDR mode select calls public API with mapped value.""" + setup_public_camera(ufp) await init_entry(hass, ufp, [doorbell]) assert_entity_counts(hass, Platform.SELECT, 5, 5) diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index 70a713d4007689..f3de49d75d2ecc 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -23,6 +23,8 @@ ) from uiprotect.data.bootstrap import ProtectDeviceRef from uiprotect.data.public_devices import ( + PublicCamera, + PublicHdrMode, PublicLight, PublicLightDeviceSettings, PublicSensor, @@ -337,6 +339,38 @@ def make_public_light( return public +_HDR_DISPLAY_TO_PUBLIC = { + "auto": PublicHdrMode.AUTO, + "always": PublicHdrMode.ON, + "off": PublicHdrMode.OFF, +} + + +def make_public_camera( + camera: Camera, + *, + state: DeviceState | None = None, + hdr_type: PublicHdrMode | None = None, +) -> Mock: + """Build a public-API camera mirroring a private camera's migrated fields. + + ``hdr_type`` defaults to the public mode derived from the private + ``hdr_mode_display`` so the migrated HDR select reads the same value the + private object would produce; pass it to diverge from that. + """ + public = Mock(spec=PublicCamera) + public.id = camera.id + public.mac = camera.mac + public.model = ModelType.CAMERA + public.state = DeviceState[camera.state.name] if state is None else state + public.hdr_type = ( + _HDR_DISPLAY_TO_PUBLIC[camera.hdr_mode_display] + if hdr_type is None + else hdr_type + ) + return public + + def setup_public_sensor( ufp: MockUFPFixture, capabilities: set[SensorFeatureCapability] | None = None, @@ -398,6 +432,33 @@ def _get(model: ModelType, obj_id: str) -> ProtectModelWithId | None: ufp.api.public_bootstrap = pb +def setup_public_camera(ufp: MockUFPFixture) -> None: + """Expose private cameras over the public API via a real ``PublicBootstrap``. + + Mirrors ``setup_public_sensor`` for ``ModelType.CAMERA`` so the migrated HDR + select reads from the public object. + """ + public_bootstrap = PublicBootstrap() + pb = Mock(spec=PublicBootstrap) + pb.cameras = public_bootstrap.cameras + pb.relays = {} + pb.sirens = {} + pb.arm_mode = None + pb.arm_profiles = {} + + def _get(model: ModelType, obj_id: str) -> ProtectModelWithId | None: + if ( + model is ModelType.CAMERA + and (private := ufp.api.bootstrap.cameras.get(obj_id)) is not None + ): + public_bootstrap.cameras[obj_id] = make_public_camera(private) + return public_bootstrap.get(model, obj_id) + + pb.get = _get + ufp.api.has_public_bootstrap = True + ufp.api.public_bootstrap = pb + + def public_device_ws_message(public_obj: Mock) -> Mock: """Build a public devices WS message carrying a public object.""" msg = Mock() diff --git a/tests/components/vesync/common.py b/tests/components/vesync/common.py index 689780a8b19faa..07076e1dd8cb81 100644 --- a/tests/components/vesync/common.py +++ b/tests/components/vesync/common.py @@ -14,6 +14,7 @@ ENTITY_HUMIDIFIER_300S_NIGHT_LIGHT_SELECT = "select.humidifier_300s_night_light_level" ENTITY_FAN = "fan.SmartTowerFan" +ENTITY_PEDESTAL_FAN = "fan.corebreeze_432s" ENTITY_SWITCH_DISPLAY = "switch.humidifier_200s_display" @@ -74,6 +75,9 @@ ("post", "/cloud/v1/deviceManaged/deviceDetail", "dimmer-detail.json") ], "SmartTowerFan": [("post", "/cloud/v2/deviceManaged/bypassV2", "fan-detail.json")], + "CoreBreeze 432S": [ + ("post", "/cloud/v2/deviceManaged/bypassV2", "pedestal-fan-detail.json") + ], "Humidifier 6000s": [ ("post", "/cloud/v2/deviceManaged/bypassV2", "humidifier-6000s-detail.json") ], diff --git a/tests/components/vesync/conftest.py b/tests/components/vesync/conftest.py index 8c9e14d0c9e762..7cf61df21e131f 100644 --- a/tests/components/vesync/conftest.py +++ b/tests/components/vesync/conftest.py @@ -331,6 +331,29 @@ async def fan_config_entry( return entry +@pytest.fixture(name="pedestal_fan_config_entry") +async def pedestal_fan_config_entry( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, config +) -> MockConfigEntry: + """Create a mock VeSync config entry for `CoreBreeze 432S`.""" + entry = MockConfigEntry( + title="VeSync", + domain=DOMAIN, + data=config[DOMAIN], + unique_id="TESTACCOUNTID", + version=1, + minor_version=3, + ) + entry.add_to_hass(hass) + + device_name = "CoreBreeze 432S" + mock_multiple_device_responses(aioclient_mock, [device_name]) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry + + @pytest.fixture(name="switch_old_id_config_entry") async def switch_old_id_config_entry( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, config diff --git a/tests/components/vesync/fixtures/pedestal-fan-detail.json b/tests/components/vesync/fixtures/pedestal-fan-detail.json new file mode 100644 index 00000000000000..b2e88fb5ef092a --- /dev/null +++ b/tests/components/vesync/fixtures/pedestal-fan-detail.json @@ -0,0 +1,33 @@ +{ + "traceId": "0000000000", + "code": 0, + "msg": "request success", + "module": null, + "stacktrace": null, + "result": { + "traceId": "0000000000", + "code": 0, + "result": { + "powerSwitch": 0, + "workMode": "normal", + "fanSpeedLevel": 1, + "temperature": 717, + "muteSwitch": 1, + "muteState": 1, + "screenState": 0, + "screenSwitch": 0, + "verticalOscillationState": 1, + "horizontalOscillationState": 1, + "childLock": 0, + "errorCode": 0, + "oscillationCoordinate": null, + "oscillationRange": null, + "sleepPreference": { + "sleepPreferenceType": 0, + "oscillationState": 0, + "fallAsleepRemain": 0, + "initFanSpeedLevel": 0 + } + } + } +} diff --git a/tests/components/vesync/fixtures/vesync-devices.json b/tests/components/vesync/fixtures/vesync-devices.json index 6b9445ceace38a..c5d5d91bbcd0bd 100644 --- a/tests/components/vesync/fixtures/vesync-devices.json +++ b/tests/components/vesync/fixtures/vesync-devices.json @@ -281,6 +281,21 @@ "subDeviceList": null, "extension": null, "deviceProp": null + }, + { + "deviceRegion": "US", + "isOwner": true, + "cid": "corebreeze432s", + "deviceType": "LPF-R432S-AEU", + "deviceName": "CoreBreeze 432S", + "deviceImg": "", + "type": "", + "connectionType": "", + "subDeviceNo": null, + "deviceStatus": "on", + "connectionStatus": "online", + "uuid": "00000000-1111-2222-3333-555555555555", + "configModule": "configModule" } ] } diff --git a/tests/components/vesync/snapshots/test_binary_sensor.ambr b/tests/components/vesync/snapshots/test_binary_sensor.ambr index 81ab447150777d..be23b3698d64aa 100644 --- a/tests/components/vesync/snapshots/test_binary_sensor.ambr +++ b/tests/components/vesync/snapshots/test_binary_sensor.ambr @@ -848,3 +848,40 @@ list([ ]) # --- +# name: test_sensor_state[CoreBreeze 432S][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'corebreeze432s', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LPF-R432S-AEU', + 'model_id': None, + 'name': 'CoreBreeze 432S', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[CoreBreeze 432S][entities] + list([ + ]) +# --- diff --git a/tests/components/vesync/snapshots/test_fan.ambr b/tests/components/vesync/snapshots/test_fan.ambr index 1ffd9f9744bb1f..75a1ac7b7f9d52 100644 --- a/tests/components/vesync/snapshots/test_fan.ambr +++ b/tests/components/vesync/snapshots/test_fan.ambr @@ -883,3 +883,108 @@ list([ ]) # --- +# name: test_fan_state[CoreBreeze 432S][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'corebreeze432s', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LPF-R432S-AEU', + 'model_id': None, + 'name': 'CoreBreeze 432S', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_fan_state[CoreBreeze 432S][entities] + list([ + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'normal', + 'sleep', + 'turbo', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.corebreeze_432s', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'vesync', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'vesync', + 'unique_id': 'corebreeze432s', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_fan_state[CoreBreeze 432S][fan.corebreeze_432s] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'active_time': None, + 'child_lock': False, + 'display_status': 'off', + : 'CoreBreeze 432S', + 'mode': , + : True, + : 8, + : 8.333333333333334, + : 'normal', + : list([ + 'normal', + 'sleep', + 'turbo', + ]), + : , + }), + 'context': , + 'entity_id': 'fan.corebreeze_432s', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/vesync/snapshots/test_humidifier.ambr b/tests/components/vesync/snapshots/test_humidifier.ambr index 4f23f141e1f594..6af8093874bd0d 100644 --- a/tests/components/vesync/snapshots/test_humidifier.ambr +++ b/tests/components/vesync/snapshots/test_humidifier.ambr @@ -755,3 +755,40 @@ list([ ]) # --- +# name: test_humidifier_state[CoreBreeze 432S][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'corebreeze432s', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LPF-R432S-AEU', + 'model_id': None, + 'name': 'CoreBreeze 432S', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_humidifier_state[CoreBreeze 432S][entities] + list([ + ]) +# --- diff --git a/tests/components/vesync/snapshots/test_light.ambr b/tests/components/vesync/snapshots/test_light.ambr index 3a8e52b765403a..a3ac56d74ad8a6 100644 --- a/tests/components/vesync/snapshots/test_light.ambr +++ b/tests/components/vesync/snapshots/test_light.ambr @@ -746,3 +746,40 @@ list([ ]) # --- +# name: test_light_state[CoreBreeze 432S][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'corebreeze432s', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LPF-R432S-AEU', + 'model_id': None, + 'name': 'CoreBreeze 432S', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_light_state[CoreBreeze 432S][entities] + list([ + ]) +# --- diff --git a/tests/components/vesync/snapshots/test_sensor.ambr b/tests/components/vesync/snapshots/test_sensor.ambr index 93753be3c2429f..d5d334e80f667f 100644 --- a/tests/components/vesync/snapshots/test_sensor.ambr +++ b/tests/components/vesync/snapshots/test_sensor.ambr @@ -2183,3 +2183,40 @@ list([ ]) # --- +# name: test_sensor_state[CoreBreeze 432S][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'corebreeze432s', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LPF-R432S-AEU', + 'model_id': None, + 'name': 'CoreBreeze 432S', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[CoreBreeze 432S][entities] + list([ + ]) +# --- diff --git a/tests/components/vesync/snapshots/test_switch.ambr b/tests/components/vesync/snapshots/test_switch.ambr index c92aff5a58cd97..a1458f6a8a361a 100644 --- a/tests/components/vesync/snapshots/test_switch.ambr +++ b/tests/components/vesync/snapshots/test_switch.ambr @@ -1420,3 +1420,136 @@ 'state': 'on', }) # --- +# name: test_switch_state[CoreBreeze 432S][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'corebreeze432s', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LPF-R432S-AEU', + 'model_id': None, + 'name': 'CoreBreeze 432S', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_switch_state[CoreBreeze 432S][entities] + list([ + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.corebreeze_432s_display', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Display', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Display', + 'platform': 'vesync', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'display', + 'unique_id': 'corebreeze432s-display', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.corebreeze_432s_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Child lock', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'vesync', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'corebreeze432s-child_lock', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_switch_state[CoreBreeze 432S][switch.corebreeze_432s_display] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'CoreBreeze 432S Display', + }), + 'context': , + 'entity_id': 'switch.corebreeze_432s_display', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_state[CoreBreeze 432S][switch.corebreeze_432s_child_lock] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'CoreBreeze 432S Child lock', + }), + 'context': , + 'entity_id': 'switch.corebreeze_432s_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/vesync/snapshots/test_update.ambr b/tests/components/vesync/snapshots/test_update.ambr index d9decbad21cf37..e45b489754aa79 100644 --- a/tests/components/vesync/snapshots/test_update.ambr +++ b/tests/components/vesync/snapshots/test_update.ambr @@ -1469,3 +1469,102 @@ 'state': 'on', }) # --- +# --- +# name: test_update_state[CoreBreeze 432S][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'corebreeze432s', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LPF-R432S-AEU', + 'model_id': None, + 'name': 'CoreBreeze 432S', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_update_state[CoreBreeze 432S][entities] + list([ + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.corebreeze_432s_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Firmware', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'vesync', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'corebreeze432s', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_update_state[CoreBreeze 432S][update.corebreeze_432s_firmware] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : False, + : 'firmware', + : 0, + : '/api/brands/integration/vesync/icon.png', + : 'CoreBreeze 432S Firmware', + : False, + : None, + : None, + : None, + : None, + : None, + : , + : None, + : None, + }), + 'context': , + 'entity_id': 'update.corebreeze_432s_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/vesync/test_fan.py b/tests/components/vesync/test_fan.py index 326729d120602d..03f862088fc476 100644 --- a/tests/components/vesync/test_fan.py +++ b/tests/components/vesync/test_fan.py @@ -12,7 +12,12 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -from .common import ALL_DEVICE_NAMES, ENTITY_FAN, mock_devices_response +from .common import ( + ALL_DEVICE_NAMES, + ENTITY_FAN, + ENTITY_PEDESTAL_FAN, + mock_devices_response, +) from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -183,6 +188,59 @@ async def test_set_preset_mode( update_mock.assert_called_once() +@pytest.mark.parametrize( + ("action", "api_response", "expectation"), + [ + ("true", True, NoException), + ("false", True, NoException), + ("true", False, pytest.raises(HomeAssistantError)), + ], +) +async def test_pedestal_fan_oscillation( + hass: HomeAssistant, + pedestal_fan_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + action: str, + api_response: bool, + expectation, +) -> None: + """Test oscillation on and off for pedestal fans. + + Pedestal fans (e.g. CoreBreeze 432S / LPF-R432S) expose vertical and + horizontal oscillation as separate toggles. The HA oscillate switch + should control both axes together. + """ + + with ( + expectation, + patch( + "pyvesync.devices.vesyncfan.VeSyncPedestalFan.toggle_vertical_oscillation", + new_callable=AsyncMock, + return_value=api_response, + ) as vertical_mock, + patch( + "pyvesync.devices.vesyncfan.VeSyncPedestalFan." + "toggle_horizontal_oscillation", + new_callable=AsyncMock, + return_value=api_response, + ) as horizontal_mock, + ): + with patch( + "homeassistant.components.vesync.fan.VeSyncFanHA.async_write_ha_state" + ) as update_mock: + await hass.services.async_call( + FAN_DOMAIN, + "oscillate", + {ATTR_ENTITY_ID: ENTITY_PEDESTAL_FAN, "oscillating": action}, + blocking=True, + ) + + await hass.async_block_till_done() + vertical_mock.assert_called_once() + horizontal_mock.assert_called_once() + update_mock.assert_called_once() + + @pytest.mark.parametrize( ("action", "command"), [ diff --git a/tests/components/vicare/fixtures/Vitocal250A_cooling.json b/tests/components/vicare/fixtures/Vitocal250A_cooling.json new file mode 100644 index 00000000000000..a8eafbdec8f079 --- /dev/null +++ b/tests/components/vicare/fixtures/Vitocal250A_cooling.json @@ -0,0 +1,6583 @@ +{ + "data": [ + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.actorSensorTest", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "status": { + "type": "string", + "value": "standby" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.actorSensorTest" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.brand", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "Viessmann" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.brand" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.configuration.houseLocation", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "altitude": { + "type": "number", + "unit": "meter", + "value": 0 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.configuration.houseLocation" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.demand.external", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.demand.external" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.lock.external", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.lock.external" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.lock.malfunction", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.lock.malfunction" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.messages.info.raw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "entries": { + "type": "array", + "value": [ + { + "accessLevel": "customer", + "audiences": [ + "IS-SUPPLIER", + "IS-DEVELOPMENT", + "IS-MANUFACTURING", + "IS-AFTERSALES", + "IS-AFTERMARKET", + "IS-DEVELOPER-VEG", + "IS-BIG-DATA", + "IS-MANUFACTURING-VEG" + ], + "errorCode": "I.114", + "priority": "info", + "timestamp": "2025-09-04T10:42:08.000Z" + } + ] + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.messages.info.raw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.messages.service.raw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "entries": { + "type": "array", + "value": [] + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.messages.service.raw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.messages.status.raw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "entries": { + "type": "array", + "value": [ + { + "accessLevel": "customer", + "audiences": [ + "IS-SUPPLIER", + "IS-DEVELOPMENT", + "IS-MANUFACTURING", + "IS-AFTERSALES", + "IS-AFTERMARKET", + "IS-DEVELOPER-VEG", + "IS-BIG-DATA", + "IS-MANUFACTURING-VEG" + ], + "errorCode": "S.134", + "priority": "status", + "timestamp": "2025-10-05T11:52:33.000Z" + }, + { + "accessLevel": "customer", + "audiences": [ + "IS-SUPPLIER", + "IS-DEVELOPMENT", + "IS-MANUFACTURING", + "IS-AFTERSALES", + "IS-AFTERMARKET", + "IS-DEVELOPER-VEG", + "IS-BIG-DATA", + "IS-MANUFACTURING-VEG" + ], + "errorCode": "S.123", + "priority": "status", + "timestamp": "2025-10-05T11:52:30.000Z" + }, + { + "accessLevel": "customer", + "audiences": [ + "IS-SUPPLIER", + "IS-DEVELOPMENT", + "IS-MANUFACTURING", + "IS-AFTERSALES", + "IS-AFTERMARKET", + "IS-DEVELOPER-VEG", + "IS-BIG-DATA", + "IS-MANUFACTURING-VEG" + ], + "errorCode": "S.165", + "priority": "status", + "timestamp": "2025-10-05T04:49:42.000Z" + }, + { + "accessLevel": "customer", + "audiences": [ + "IS-SUPPLIER", + "IS-DEVELOPMENT", + "IS-MANUFACTURING", + "IS-AFTERSALES", + "IS-AFTERMARKET", + "IS-DEVELOPER-VEG", + "IS-BIG-DATA", + "IS-MANUFACTURING-VEG" + ], + "errorCode": "S.120", + "priority": "status", + "timestamp": "2025-09-04T10:42:08.000Z" + }, + { + "accessLevel": "customer", + "audiences": [ + "IS-SUPPLIER", + "IS-DEVELOPMENT", + "IS-MANUFACTURING", + "IS-AFTERSALES", + "IS-AFTERMARKET", + "IS-DEVELOPER-VEG", + "IS-BIG-DATA", + "IS-MANUFACTURING-VEG" + ], + "errorCode": "S.219", + "priority": "status", + "timestamp": "2025-09-04T10:42:05.000Z" + }, + { + "accessLevel": "customer", + "audiences": [ + "IS-SUPPLIER", + "IS-DEVELOPMENT", + "IS-MANUFACTURING", + "IS-AFTERSALES", + "IS-AFTERMARKET", + "IS-DEVELOPER-VEG", + "IS-BIG-DATA", + "IS-MANUFACTURING-VEG" + ], + "errorCode": "S.218", + "priority": "status", + "timestamp": "2025-09-04T10:42:05.000Z" + }, + { + "accessLevel": "customer", + "audiences": [ + "IS-SUPPLIER", + "IS-DEVELOPMENT", + "IS-MANUFACTURING", + "IS-AFTERSALES", + "IS-AFTERMARKET", + "IS-DEVELOPER-VEG", + "IS-BIG-DATA", + "IS-MANUFACTURING-VEG" + ], + "errorCode": "S.217", + "priority": "status", + "timestamp": "2025-09-04T10:42:05.000Z" + }, + { + "accessLevel": "customer", + "audiences": [ + "IS-SUPPLIER", + "IS-DEVELOPMENT", + "IS-MANUFACTURING", + "IS-AFTERSALES", + "IS-AFTERMARKET", + "IS-DEVELOPER-VEG", + "IS-BIG-DATA", + "IS-MANUFACTURING-VEG" + ], + "errorCode": "S.165", + "priority": "status", + "timestamp": "2025-09-04T10:42:05.000Z" + } + ] + } + }, + "timestamp": "2025-10-05T09:41:49.137Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.messages.status.raw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.parameterIdentification.version", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "0030.0515.2501.0054" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.parameterIdentification.version" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.power.consumption.limitation", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "14aOff" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.power.consumption.limitation" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.power.statusReport.consumption", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "limit": { + "type": "number", + "unit": "watt", + "value": 0 + }, + "status": { + "type": "string", + "value": "unlimitedAutonomous" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.power.statusReport.consumption" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.power.statusReport.production", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "limit": { + "type": "number", + "unit": "watt", + "value": 0 + }, + "status": { + "type": "string", + "value": "init" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.power.statusReport.production" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.productIdentification", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "product": { + "type": "object", + "value": { + "busAddress": 1, + "busType": "CanExternal", + "productFamily": "B_00027_VC250", + "viessmannIdentificationNumber": "################" + } + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.productIdentification" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.productMatrix", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "product": { + "type": "array", + "value": [ + { + "busAddress": 1, + "busType": "CanExternal", + "productFamily": "B_00027_VC250", + "viessmannIdentificationNumber": "################" + } + ] + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.productMatrix" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.remoteReset", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.remoteReset" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.serial", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "deviceSerialVitocal250A" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.serial" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.setDefaultValues", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.setDefaultValues" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": { + "begin": { + "constraints": { + "regEx": "^[\\d]{2}-[\\d]{2}$" + }, + "required": true, + "type": "string" + }, + "end": { + "constraints": { + "regEx": "^[\\d]{2}-[\\d]{2}$" + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.time.daylightSaving/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.time.daylightSaving/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "device.time.daylightSaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "begin": { + "type": "string", + "value": "25-03" + }, + "end": { + "type": "string", + "value": "25-10" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.time.daylightSaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.type", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "mono" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.type" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.variant", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "Vitocal250A" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.variant" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.zigbee.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.zigbee.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.zigbee.status", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.zigbee.status" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.boiler.pumps.internal", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "off" + } + }, + "timestamp": "2025-10-05T09:41:43.236Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.boiler.pumps.internal" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.boiler.pumps.internal.current", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "percent", + "value": 0 + } + }, + "timestamp": "2025-10-05T09:41:58.952Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.boiler.pumps.internal.current" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.boiler.pumps.internal.target", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "percent", + "value": 0 + } + }, + "timestamp": "2025-10-05T09:41:43.236Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.boiler.pumps.internal.target" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.boiler.sensors.temperature.commonSupply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 33.1 + } + }, + "timestamp": "2025-10-05T10:05:23.125Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.boiler.sensors.temperature.commonSupply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.boiler.serial", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "################" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.boiler.serial" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.boiler.temperature.current", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "celsius", + "value": 0 + } + }, + "timestamp": "2025-10-05T09:39:36.387Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.boiler.temperature.current" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.bufferCylinder.sensors.temperature.main", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.buffer.sensors.temperature.main", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 29.9 + } + }, + "timestamp": "2025-10-05T10:06:21.715Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.buffer.sensors.temperature.main" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.bufferCylinder.sensors.temperature.main", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 29.9 + } + }, + "timestamp": "2025-10-05T10:06:21.715Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.bufferCylinder.sensors.temperature.main" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "enabled": { + "type": "array", + "value": ["0"] + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits" + }, + { + "apiVersion": 1, + "commands": { + "setName": { + "isExecutable": true, + "name": "setName", + "params": { + "name": { + "constraints": { + "maxLength": 39, + "minLength": 1 + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0/commands/setName" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "name": { + "type": "string", + "value": "" + }, + "type": { + "type": "string", + "value": "heatingCircuit" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.circulation.pump", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "on" + } + }, + "timestamp": "2025-10-05T09:41:43.236Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.circulation.pump" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.configuration.summerEco.absolute", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "threshold": { + "type": "number", + "unit": "celsius", + "value": 15 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.configuration.summerEco.absolute" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.cooling.hysteresis", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.cooling.hysteresis" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.cooling.hysteresis.switch", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.cooling.hysteresis.switch" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.frostprotection", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "off" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.frostprotection" + }, + { + "apiVersion": 1, + "commands": { + "setCurve": { + "isExecutable": true, + "name": "setCurve", + "params": { + "shift": { + "constraints": { + "max": 40, + "min": -13, + "stepping": 1 + }, + "required": true, + "type": "number" + }, + "slope": { + "constraints": { + "max": 3.5, + "min": 0.2, + "stepping": 0.1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.curve/commands/setCurve" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.heating.curve", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "shift": { + "type": "number", + "unit": "", + "value": 4 + }, + "slope": { + "type": "number", + "unit": "", + "value": 0.6 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.curve" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.heating.hysteresis.switch", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.hysteresis.switch" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": true, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "reduced", + "maxEntries": 4, + "modes": ["normal", "comfort"], + "overlapAllowed": false, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.heating.schedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "entries": { + "type": "Schedule", + "value": { + "fri": [ + { + "end": "03:00", + "mode": "normal", + "position": 0, + "start": "00:00" + }, + { + "end": "05:00", + "mode": "comfort", + "position": 1, + "start": "03:00" + }, + { + "end": "24:00", + "mode": "normal", + "position": 2, + "start": "05:00" + } + ], + "mon": [ + { + "end": "03:00", + "mode": "normal", + "position": 0, + "start": "00:00" + }, + { + "end": "05:00", + "mode": "comfort", + "position": 1, + "start": "03:00" + }, + { + "end": "24:00", + "mode": "normal", + "position": 2, + "start": "05:00" + } + ], + "sat": [ + { + "end": "03:00", + "mode": "normal", + "position": 0, + "start": "00:00" + }, + { + "end": "05:00", + "mode": "comfort", + "position": 1, + "start": "03:00" + }, + { + "end": "24:00", + "mode": "normal", + "position": 2, + "start": "05:00" + } + ], + "sun": [ + { + "end": "03:00", + "mode": "normal", + "position": 0, + "start": "00:00" + }, + { + "end": "05:00", + "mode": "comfort", + "position": 1, + "start": "03:00" + }, + { + "end": "24:00", + "mode": "normal", + "position": 2, + "start": "05:00" + } + ], + "thu": [ + { + "end": "03:00", + "mode": "normal", + "position": 0, + "start": "00:00" + }, + { + "end": "05:00", + "mode": "comfort", + "position": 1, + "start": "03:00" + }, + { + "end": "24:00", + "mode": "normal", + "position": 2, + "start": "05:00" + } + ], + "tue": [ + { + "end": "03:00", + "mode": "normal", + "position": 0, + "start": "00:00" + }, + { + "end": "05:00", + "mode": "comfort", + "position": 1, + "start": "03:00" + }, + { + "end": "24:00", + "mode": "normal", + "position": 2, + "start": "05:00" + } + ], + "wed": [ + { + "end": "03:00", + "mode": "normal", + "position": 0, + "start": "00:00" + }, + { + "end": "05:00", + "mode": "comfort", + "position": 1, + "start": "03:00" + }, + { + "end": "24:00", + "mode": "normal", + "position": 2, + "start": "05:00" + } + ] + } + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.schedule" + }, + { + "apiVersion": 1, + "commands": { + "setName": { + "isExecutable": true, + "name": "setName", + "params": { + "name": { + "constraints": { + "maxLength": 39, + "minLength": 1 + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.name/commands/setName" + } + }, + "components": [], + "deviceId": "0", + "feature": "heating.circuits.0.name", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "name": { + "type": "string", + "value": "" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.name" + }, + { + "apiVersion": 1, + "commands": { + "setMode": { + "isExecutable": true, + "name": "setMode", + "params": { + "mode": { + "constraints": { + "enum": ["cooling", "heating", "heatingCooling", "standby"] + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.active/commands/setMode" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "cooling" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.heatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.standby", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "normalHeating" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.comfortCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfortCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.comfortCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "cooling" + }, + "reason": { + "type": "string", + "value": "summerEco" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfortCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.comfortEnergySaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "heating" + }, + "reason": { + "type": "string", + "value": "summerEco" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfortEnergySaving" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": false, + "name": "activate", + "params": { + "temperature": { + "constraints": { + "max": 37, + "min": 3, + "stepping": 1 + }, + "required": false, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfortHeating/commands/activate" + }, + "deactivate": { + "isExecutable": false, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfortHeating/commands/deactivate" + }, + "setTemperature": { + "isExecutable": true, + "name": "setTemperature", + "params": { + "targetTemperature": { + "constraints": { + "max": 37, + "min": 3, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfortHeating/commands/setTemperature" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.comfortHeating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "heating" + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 22 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfortHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.fixed" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.forcedLastFromSchedule/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.forcedLastFromSchedule/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.forcedLastFromSchedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.forcedLastFromSchedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.frostprotection", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.normalCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normalCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.normalCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "cooling" + }, + "reason": { + "type": "string", + "value": "summerEco" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normalCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.normalEnergySaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "heating" + }, + "reason": { + "type": "string", + "value": "summerEco" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normalEnergySaving" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": false, + "name": "activate", + "params": { + "temperature": { + "constraints": { + "max": 37, + "min": 3, + "stepping": 1 + }, + "required": false, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normalHeating/commands/activate" + }, + "deactivate": { + "isExecutable": false, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normalHeating/commands/deactivate" + }, + "setTemperature": { + "isExecutable": true, + "name": "setTemperature", + "params": { + "targetTemperature": { + "constraints": { + "max": 37, + "min": 3, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normalHeating/commands/setTemperature" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.normalHeating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "demand": { + "type": "string", + "value": "heating" + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 20 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normalHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.reducedCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reducedCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.reducedCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "cooling" + }, + "reason": { + "type": "string", + "value": "summerEco" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reducedCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.reducedEnergySaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "heating" + }, + "reason": { + "type": "string", + "value": "unknown" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reducedEnergySaving" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": false, + "name": "activate", + "params": { + "temperature": { + "constraints": { + "max": 37, + "min": 3, + "stepping": 1 + }, + "required": false, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reducedHeating/commands/activate" + }, + "deactivate": { + "isExecutable": false, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reducedHeating/commands/deactivate" + }, + "setTemperature": { + "isExecutable": true, + "name": "setTemperature", + "params": { + "targetTemperature": { + "constraints": { + "max": 37, + "min": 3, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reducedHeating/commands/setTemperature" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.reducedHeating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "heating" + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 18 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reducedHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.standby", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.summerEco", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.summerEco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.sensors.humidity.dewpoint", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.sensors.humidity.dewpoint" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.sensors.temperature.room" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 29.8 + } + }, + "timestamp": "2025-10-05T10:09:15.988Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.temperature", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "celsius", + "value": 30 + } + }, + "timestamp": "2025-10-05T09:43:37.884Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature" + }, + { + "apiVersion": 1, + "commands": { + "setLevels": { + "isExecutable": true, + "name": "setLevels", + "params": { + "maxTemperature": { + "constraints": { + "max": 70, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + }, + "minTemperature": { + "constraints": { + "max": 30, + "min": 1, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature.levels/commands/setLevels" + }, + "setMax": { + "isExecutable": true, + "name": "setMax", + "params": { + "temperature": { + "constraints": { + "max": 70, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature.levels/commands/setMax" + }, + "setMin": { + "isExecutable": true, + "name": "setMin", + "params": { + "temperature": { + "constraints": { + "max": 30, + "min": 1, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature.levels/commands/setMin" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.temperature.levels", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "max": { + "type": "number", + "unit": "celsius", + "value": 60 + }, + "min": { + "type": "number", + "unit": "celsius", + "value": 20 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature.levels" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.zone.demand", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.zone.demand" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.zone.mode", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.zone.mode" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.circulation.pump", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T09:41:43.236Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.circulation.pump" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.configuration.summerEco.absolute", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.configuration.summerEco.absolute" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.cooling.hysteresis", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.cooling.hysteresis" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.cooling.hysteresis.switch", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.cooling.hysteresis.switch" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.heating.curve", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.heating.curve" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.heating.hysteresis.switch", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.heating.hysteresis.switch" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.heating.schedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.heating.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.heating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.heatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.comfortCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfortCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.comfortCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfortCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.comfortEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfortEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.comfortHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfortHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.fixed" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.forcedLastFromSchedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.forcedLastFromSchedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.normalCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normalCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.normalCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normalCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.normalEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normalEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.normalHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normalHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.reducedCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reducedCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.reducedCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reducedCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.reducedEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reducedEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.reducedHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reducedHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.summerEco", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.summerEco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.sensors.humidity.dewpoint", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.sensors.humidity.dewpoint" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.sensors.temperature.room" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.temperature", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.temperature" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.temperature.levels", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.temperature.levels" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.zone.demand", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.zone.demand" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.zone.mode", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.zone.mode" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.circulation.pump", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T09:41:43.236Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.circulation.pump" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.configuration.summerEco.absolute", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.configuration.summerEco.absolute" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.cooling.hysteresis", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.cooling.hysteresis" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.cooling.hysteresis.switch", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.cooling.hysteresis.switch" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.heating.curve", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.heating.curve" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.heating.hysteresis.switch", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.heating.hysteresis.switch" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.heating.schedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.heating.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.heating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.heatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.comfortCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.comfortCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.comfortCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.comfortCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.comfortEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.comfortEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.comfortHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.comfortHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.fixed" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.forcedLastFromSchedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.forcedLastFromSchedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.normalCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.normalCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.normalCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.normalCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.normalEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.normalEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.normalHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.normalHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.reducedCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.reducedCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.reducedCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.reducedCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.reducedEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.reducedEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.reducedHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.reducedHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.summerEco", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.summerEco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.sensors.temperature.room" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.temperature", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.temperature" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.temperature.levels", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.temperature.levels" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.zone.demand", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.zone.demand" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.zone.mode", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.zone.mode" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.circulation.pump", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T09:41:43.236Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.circulation.pump" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.configuration.summerEco.absolute", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.configuration.summerEco.absolute" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.cooling.hysteresis", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.cooling.hysteresis" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.cooling.hysteresis.switch", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.cooling.hysteresis.switch" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.heating.curve", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.heating.curve" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.heating.hysteresis.switch", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.heating.hysteresis.switch" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.heating.schedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.heating.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.modes.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.modes.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.modes.heating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.modes.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.modes.heatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.modes.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.modes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.comfortCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.comfortCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.comfortCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.comfortCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.comfortEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.comfortEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.comfortHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.comfortHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.fixed" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.forcedLastFromSchedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.forcedLastFromSchedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.normalCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.normalCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.normalCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.normalCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.normalEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.normalEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.normalHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.normalHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.reducedCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.reducedCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.reducedCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.reducedCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.reducedEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.reducedEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.reducedHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.reducedHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.summerEco", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.summerEco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.sensors.temperature.room" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.temperature", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.temperature" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.temperature.levels", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.temperature.levels" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.zone.demand", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.zone.demand" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.zone.mode", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.zone.mode" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "enabled": { + "type": "array", + "value": ["0"] + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.compressors" + }, + { + "apiVersion": 1, + "commands": { + "setActive": { + "isExecutable": false, + "name": "setActive", + "params": { + "active": { + "constraints": {}, + "required": true, + "type": "boolean" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0/commands/setActive" + }, + "setPhase": { + "isExecutable": false, + "name": "setPhase", + "params": { + "value": { + "constraints": { + "enum": ["off", "preparing", "not-ready", "ready"] + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0/commands/setPhase" + } + }, + "deviceId": "0", + "feature": "heating.compressors.0", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "phase": { + "type": "string", + "value": "ready" + } + }, + "timestamp": "2025-10-05T09:39:40.888Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.heater.crankcase", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.heater.crankcase" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.sensors.pressure.inlet", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "bar", + "value": 6.66 + } + }, + "timestamp": "2025-10-05T10:09:28.719Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.sensors.pressure.inlet" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.sensors.temperature.inlet", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 20.9 + } + }, + "timestamp": "2025-10-05T10:04:30.177Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.sensors.temperature.inlet" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.sensors.temperature.motorChamber", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 24.2 + } + }, + "timestamp": "2025-10-05T10:00:19.787Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.sensors.temperature.motorChamber" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.sensors.temperature.oil", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 41.3 + } + }, + "timestamp": "2025-10-05T10:08:15.229Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.sensors.temperature.oil" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.sensors.temperature.outlet", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 25.3 + } + }, + "timestamp": "2025-10-05T10:01:46.601Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.sensors.temperature.outlet" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.speed.current", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "revolutionsPerSecond", + "value": 0 + } + }, + "timestamp": "2025-10-05T09:39:54.797Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.speed.current" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.statistics", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "hours": { + "type": "number", + "unit": "hour", + "value": 8118 + }, + "starts": { + "type": "number", + "unit": "", + "value": 1502 + } + }, + "timestamp": "2025-10-05T09:15:09.300Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.statistics" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.condensors.0.sensors.temperature.liquid", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 25.9 + } + }, + "timestamp": "2025-10-05T09:50:27.932Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.condensors.0.sensors.temperature.liquid" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.configuration.bufferCylinderSize", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "liter", + "value": 0 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.bufferCylinderSize" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.configuration.centralHeatingCylinderSize", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "liter", + "value": 0 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.centralHeatingCylinderSize" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.configuration.highDemand.threshold", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.configuration.dhw.highDemand.threshold", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.dhw.highDemand.threshold" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.configuration.highDemand.timeframe", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.configuration.dhw.highDemand.timeframe", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.dhw.highDemand.timeframe" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.configuration.temperature.comfortCharging", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.configuration.dhw.temperature.comfortCharging", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.dhw.temperature.comfortCharging" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.configuration.dhwCylinderSize", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "liter", + "value": 0 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.dhwCylinderSize" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.configuration.heatingRod.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "useApproved": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.heatingRod.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.configuration.heatingRod.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "useApproved": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.heatingRod.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.configuration.houseHeatingLoad", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "kilowattHour/year", + "value": 0 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.houseHeatingLoad" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by device.configuration.houseLocation", + "removalDate": "2025-03-15" + }, + "deviceId": "0", + "feature": "heating.configuration.houseLocation", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "altitude": { + "type": "number", + "unit": "meter", + "value": 0 + }, + "latitude": { + "type": "number", + "unit": "degree", + "value": 0 + }, + "longitude": { + "type": "number", + "unit": "degree", + "value": 0 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.houseLocation" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.configuration.houseOrientation", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "horizontal": { + "type": "number", + "unit": "degree", + "value": 0 + }, + "vertical": { + "type": "number", + "unit": "degree", + "value": 0 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.houseOrientation" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.configuration.internalPumpOne", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "defaultLimit": { + "type": "number", + "unit": "percent", + "value": 90 + }, + "maximumLimit": { + "type": "number", + "unit": "percent", + "value": 100 + }, + "minimumLimit": { + "type": "number", + "unit": "percent", + "value": 20 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.internalPumpOne" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.configuration.internalPumpTwo", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "defaultLimit": { + "type": "number", + "unit": "percent", + "value": 90 + }, + "maximumLimit": { + "type": "number", + "unit": "percent", + "value": 100 + }, + "minimumLimit": { + "type": "number", + "unit": "percent", + "value": 20 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.internalPumpTwo" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.configuration.internalPumps", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "defaultLimit": { + "type": "number", + "unit": "percent", + "value": 95 + }, + "maximumLimit": { + "type": "number", + "unit": "percent", + "value": 100 + }, + "minimumLimit": { + "type": "number", + "unit": "percent", + "value": 20 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.internalPumps" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.configuration.temperature.outside.DampingFactor", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "minute", + "value": 10 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.temperature.outside.DampingFactor" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.device.time", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T10:06:38.742Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.device.time" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by device.variant", + "removalDate": "2025-03-15" + }, + "deviceId": "0", + "feature": "heating.device.variant", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "Vitocal250A" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.device.variant" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "status": { + "type": "string", + "value": "on" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.actuator", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.actuator" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.configuration.highDemand.threshold", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.configuration.highDemand.threshold" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.configuration.highDemand.timeframe", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.configuration.highDemand.timeframe" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.configuration.temperature.comfortCharging", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.configuration.temperature.comfortCharging" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": false, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.hygiene/commands/activate" + }, + "disable": { + "isExecutable": false, + "name": "disable", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.hygiene/commands/disable" + }, + "enable": { + "isExecutable": true, + "name": "enable", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.hygiene/commands/enable" + } + }, + "deviceId": "0", + "feature": "heating.dhw.hygiene", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "enabled": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.hygiene" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.hygiene.trigger", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.hygiene.trigger" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge/commands/deactivate" + }, + "setActive": { + "isExecutable": true, + "name": "setActive", + "params": { + "active": { + "constraints": {}, + "required": true, + "type": "boolean" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge/commands/setActive" + } + }, + "deviceId": "0", + "feature": "heating.dhw.oneTimeCharge", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge" + }, + { + "apiVersion": 1, + "commands": { + "setMode": { + "isExecutable": true, + "name": "setMode", + "params": { + "mode": { + "constraints": { + "enum": ["efficientWithMinComfort", "efficient", "off"] + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.active/commands/setMode" + } + }, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "efficient" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.balanced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.balanced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.comfort", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.comfort" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.eco", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.eco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.efficient", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.efficient" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.efficientWithMinComfort", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.efficientWithMinComfort" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.off", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.off" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.pumps.circulation", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "on" + } + }, + "timestamp": "2025-10-05T10:09:08.866Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.circulation" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": true, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.circulation.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "off", + "maxEntries": 4, + "modes": ["on"], + "overlapAllowed": false, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.circulation.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "heating.dhw.pumps.circulation.schedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "entries": { + "type": "Schedule", + "value": { + "fri": [ + { + "end": "09:00", + "mode": "on", + "position": 0, + "start": "05:30" + }, + { + "end": "13:00", + "mode": "on", + "position": 1, + "start": "12:00" + }, + { + "end": "20:00", + "mode": "on", + "position": 2, + "start": "18:00" + } + ], + "mon": [ + { + "end": "09:00", + "mode": "on", + "position": 0, + "start": "05:30" + }, + { + "end": "13:00", + "mode": "on", + "position": 1, + "start": "12:00" + }, + { + "end": "20:00", + "mode": "on", + "position": 2, + "start": "18:00" + } + ], + "sat": [ + { + "end": "09:30", + "mode": "on", + "position": 0, + "start": "07:00" + }, + { + "end": "13:00", + "mode": "on", + "position": 1, + "start": "12:00" + }, + { + "end": "20:00", + "mode": "on", + "position": 2, + "start": "18:00" + } + ], + "sun": [ + { + "end": "09:30", + "mode": "on", + "position": 0, + "start": "07:00" + }, + { + "end": "13:00", + "mode": "on", + "position": 1, + "start": "12:00" + }, + { + "end": "20:00", + "mode": "on", + "position": 2, + "start": "18:00" + } + ], + "thu": [ + { + "end": "09:00", + "mode": "on", + "position": 0, + "start": "05:30" + }, + { + "end": "13:00", + "mode": "on", + "position": 1, + "start": "12:00" + }, + { + "end": "20:00", + "mode": "on", + "position": 2, + "start": "18:00" + } + ], + "tue": [ + { + "end": "09:00", + "mode": "on", + "position": 0, + "start": "05:30" + }, + { + "end": "13:00", + "mode": "on", + "position": 1, + "start": "12:00" + }, + { + "end": "20:00", + "mode": "on", + "position": 2, + "start": "18:00" + } + ], + "wed": [ + { + "end": "09:00", + "mode": "on", + "position": 0, + "start": "05:30" + }, + { + "end": "13:00", + "mode": "on", + "position": 1, + "start": "12:00" + }, + { + "end": "20:00", + "mode": "on", + "position": 2, + "start": "18:00" + } + ] + } + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.circulation.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.pumps.secondary", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.secondary" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": true, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "off", + "maxEntries": 4, + "modes": ["on"], + "overlapAllowed": false, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "heating.dhw.schedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "entries": { + "type": "Schedule", + "value": { + "fri": [ + { + "end": "03:00", + "mode": "on", + "position": 0, + "start": "01:00" + }, + { + "end": "15:00", + "mode": "on", + "position": 1, + "start": "13:00" + } + ], + "mon": [ + { + "end": "03:00", + "mode": "on", + "position": 0, + "start": "01:00" + }, + { + "end": "15:00", + "mode": "on", + "position": 1, + "start": "13:00" + } + ], + "sat": [ + { + "end": "03:00", + "mode": "on", + "position": 0, + "start": "01:00" + }, + { + "end": "15:00", + "mode": "on", + "position": 1, + "start": "13:00" + } + ], + "sun": [ + { + "end": "03:00", + "mode": "on", + "position": 0, + "start": "01:00" + }, + { + "end": "15:00", + "mode": "on", + "position": 1, + "start": "13:00" + } + ], + "thu": [ + { + "end": "03:00", + "mode": "on", + "position": 0, + "start": "01:00" + }, + { + "end": "15:00", + "mode": "on", + "position": 1, + "start": "13:00" + } + ], + "tue": [ + { + "end": "03:00", + "mode": "on", + "position": 0, + "start": "01:00" + }, + { + "end": "15:00", + "mode": "on", + "position": 1, + "start": "13:00" + } + ], + "wed": [ + { + "end": "03:00", + "mode": "on", + "position": 0, + "start": "01:00" + }, + { + "end": "15:00", + "mode": "on", + "position": 1, + "start": "13:00" + } + ] + } + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.dhwCylinder", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 46.8 + } + }, + "timestamp": "2025-10-05T09:53:51.524Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.dhwCylinder" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.dhwCylinder.middle", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.dhwCylinder.middle" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.dhwCylinder.top", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T09:53:51.524Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.dhwCylinder.top" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.sensors.temperature.dhwCylinder", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.hotWaterStorage", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 46.8 + } + }, + "timestamp": "2025-10-05T09:53:51.524Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.hotWaterStorage" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.sensors.temperature.dhwCylinder.middle", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.hotWaterStorage.middle", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.hotWaterStorage.middle" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.sensors.temperature.dhwCylinder.top", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.hotWaterStorage.top", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T09:53:51.524Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.hotWaterStorage.top" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.tankLoadSystem.return", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.tankLoadSystem.return" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.tankLoadSystem.supply", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.tankLoadSystem.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.temperature.hygiene", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hygiene" + }, + { + "apiVersion": 1, + "commands": { + "setHysteresis": { + "isExecutable": true, + "name": "setHysteresis", + "params": { + "hysteresis": { + "constraints": { + "max": 10, + "min": 1, + "stepping": 0.5 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis/commands/setHysteresis" + }, + "setHysteresisSwitchOffValue": { + "isExecutable": true, + "name": "setHysteresisSwitchOffValue", + "params": { + "hysteresis": { + "constraints": { + "max": 2.5, + "min": 0, + "stepping": 0.5 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis/commands/setHysteresisSwitchOffValue" + }, + "setHysteresisSwitchOnValue": { + "isExecutable": true, + "name": "setHysteresisSwitchOnValue", + "params": { + "hysteresis": { + "constraints": { + "max": 10, + "min": 1, + "stepping": 0.5 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis/commands/setHysteresisSwitchOnValue" + } + }, + "deviceId": "0", + "feature": "heating.dhw.temperature.hysteresis", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "switchOffValue": { + "type": "number", + "unit": "kelvin", + "value": 0 + }, + "switchOnValue": { + "type": "number", + "unit": "kelvin", + "value": 5 + }, + "value": { + "type": "number", + "unit": "kelvin", + "value": 5 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.temperature.levels", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "default": { + "type": "number", + "unit": "celsius", + "value": 50 + }, + "max": { + "type": "number", + "unit": "celsius", + "value": 10 + }, + "min": { + "type": "number", + "unit": "celsius", + "value": 10 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.levels" + }, + { + "apiVersion": 1, + "commands": { + "setTargetTemperature": { + "isExecutable": true, + "name": "setTargetTemperature", + "params": { + "temperature": { + "constraints": { + "efficientLowerBorder": 0, + "efficientUpperBorder": 55, + "max": 60, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.main/commands/setTargetTemperature" + } + }, + "deviceId": "0", + "feature": "heating.dhw.temperature.main", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "celsius", + "value": 55 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.main" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.economizers.0.sensors.temperature.liquid", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 24.1 + } + }, + "timestamp": "2025-10-05T10:07:59.252Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.economizers.0.sensors.temperature.liquid" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.evaporators.0.heater.base", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.evaporators.0.heater.base" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.evaporators.0.sensors.temperature.liquid", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 13.9 + } + }, + "timestamp": "2025-10-05T10:09:04.503Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.evaporators.0.sensors.temperature.liquid" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.evaporators.0.sensors.temperature.overheat", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 20 + } + }, + "timestamp": "2025-10-05T10:05:56.139Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.evaporators.0.sensors.temperature.overheat" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by device.lock.external", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.external.lock", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.external.lock" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heat.production.summary.cooling", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "currentDay": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "currentMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "currentYear": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastSevenDays": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastYear": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.heat.production.summary.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heat.production.summary.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "currentDay": { + "type": "number", + "unit": "kilowattHour", + "value": 9.6 + }, + "currentMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 61.8 + }, + "currentYear": { + "type": "number", + "unit": "kilowattHour", + "value": 3382.9 + }, + "lastMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 389.9 + }, + "lastSevenDays": { + "type": "number", + "unit": "kilowattHour", + "value": 96.7 + }, + "lastYear": { + "type": "number", + "unit": "kilowattHour", + "value": 5903.8 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.heat.production.summary.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heat.production.summary.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "currentDay": { + "type": "number", + "unit": "kilowattHour", + "value": 22.8 + }, + "currentMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 164.3 + }, + "currentYear": { + "type": "number", + "unit": "kilowattHour", + "value": 10024.2 + }, + "lastMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 183.1 + }, + "lastSevenDays": { + "type": "number", + "unit": "kilowattHour", + "value": 206.3 + }, + "lastYear": { + "type": "number", + "unit": "kilowattHour", + "value": 15472.4 + } + }, + "timestamp": "2025-10-05T09:39:30.539Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.heat.production.summary.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heater.condensatePan", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.heater.condensatePan" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heater.fanRing", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.heater.fanRing" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod.maximumOutsideTemperature", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod.maximumOutsideTemperature" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod.power.consumption.summary.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "currentDay": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "currentMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "currentYear": { + "type": "number", + "unit": "kilowattHour", + "value": 3.3 + }, + "lastMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastSevenDays": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastYear": { + "type": "number", + "unit": "kilowattHour", + "value": 28 + } + }, + "timestamp": "2025-10-05T05:14:18.020Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod.power.consumption.summary.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod.power.consumption.summary.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "currentDay": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "currentMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "currentYear": { + "type": "number", + "unit": "kilowattHour", + "value": 29.5 + }, + "lastMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastSevenDays": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastYear": { + "type": "number", + "unit": "kilowattHour", + "value": 53.6 + } + }, + "timestamp": "2025-10-05T05:14:18.020Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod.power.consumption.summary.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod.statistics", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "hours": { + "type": "number", + "unit": "hour", + "value": 31 + }, + "starts": { + "type": "number", + "unit": "", + "value": 314 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod.statistics" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.inverters.0.sensors.power.current", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "ampere", + "value": 0 + } + }, + "timestamp": "2025-10-05T09:39:48.533Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.inverters.0.sensors.power.current" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.inverters.0.sensors.power.output", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "watt", + "value": 0 + } + }, + "timestamp": "2025-10-05T09:39:48.533Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.inverters.0.sensors.power.output" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.inverters.0.sensors.temperature.powerModule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 26.3 + } + }, + "timestamp": "2025-10-05T10:08:59.620Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.inverters.0.sensors.temperature.powerModule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.noise.reduction.levels.maxReduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.noise.reduction.levels.maxReduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.noise.reduction.levels.notReduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.noise.reduction.levels.notReduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.noise.reduction.levels.slightlyReduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.noise.reduction.levels.slightlyReduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.noise.reduction.operating.state", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.noise.reduction.operating.programs.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.noise.reduction.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.noise.reduction.levels.maxReduced", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.noise.reduction.operating.programs.maxReduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.noise.reduction.operating.programs.maxReduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.noise.reduction.levels.notReduced", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.noise.reduction.operating.programs.notReduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.noise.reduction.operating.programs.notReduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.noise.reduction.levels.slightlyReduced", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.noise.reduction.operating.programs.slightlyReduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.noise.reduction.operating.programs.slightlyReduced" + }, + { + "apiVersion": 1, + "commands": { + "changeEndDate": { + "isExecutable": false, + "name": "changeEndDate", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": true + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday/commands/changeEndDate" + }, + "schedule": { + "isExecutable": true, + "name": "schedule", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": true + }, + "required": true, + "type": "string" + }, + "start": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$" + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday/commands/schedule" + }, + "unschedule": { + "isExecutable": true, + "name": "unschedule", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday/commands/unschedule" + } + }, + "deviceId": "0", + "feature": "heating.operating.programs.holiday", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "end": { + "type": "string", + "value": "2000-01-01" + }, + "start": { + "type": "string", + "value": "2000-01-01" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday" + }, + { + "apiVersion": 1, + "commands": { + "changeEndDate": { + "isExecutable": false, + "name": "changeEndDate", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": true + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holidayAtHome/commands/changeEndDate" + }, + "schedule": { + "isExecutable": true, + "name": "schedule", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": true + }, + "required": true, + "type": "string" + }, + "start": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$" + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holidayAtHome/commands/schedule" + }, + "unschedule": { + "isExecutable": true, + "name": "unschedule", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holidayAtHome/commands/unschedule" + } + }, + "deviceId": "0", + "feature": "heating.operating.programs.holidayAtHome", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "end": { + "type": "string", + "value": "2000-01-01" + }, + "start": { + "type": "string", + "value": "2000-01-01" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holidayAtHome" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.outdoor.defrosting", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.outdoor.defrosting" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.outdoor.defrosting.thermalEnergy", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T10:08:59.620Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.outdoor.defrosting.thermalEnergy" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:18.020Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "day": { + "type": "array", + "unit": "kilowattHour", + "value": [2.6, 5.8, 3.5, 2.3, 1.8, 5.1, 3.4, 5.4] + }, + "dayValueReadAt": { + "type": "string", + "value": "2025-10-05T05:14:16.233Z" + }, + "month": { + "type": "array", + "unit": "kilowattHour", + "value": [ + 16, 89.7, 28.1, 41.7, 31.5, 54.9, 76.3, 144.2, 178.4, 214.3, 210, + 178.7, 128.4 + ] + }, + "monthValueReadAt": { + "type": "string", + "value": "2025-10-05T05:14:16.233Z" + }, + "week": { + "type": "array", + "unit": "kilowattHour", + "value": [24.5, 32.7, 13.8, 22.4, 12.3] + }, + "weekValueReadAt": { + "type": "string", + "value": "2025-10-05T05:14:16.233Z" + }, + "year": { + "type": "array", + "unit": "kilowattHour", + "value": [875.0999999999999, 1536.8] + }, + "yearValueReadAt": { + "type": "string", + "value": "2025-10-05T05:14:16.233Z" + } + }, + "timestamp": "2025-10-05T05:14:18.020Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "day": { + "type": "array", + "unit": "kilowattHour", + "value": [4.6, 6.8, 7, 6.1, 7.8, 4.3, 2.8, 4.6] + }, + "dayValueReadAt": { + "type": "string", + "value": "2025-10-05T05:14:16.246Z" + }, + "month": { + "type": "array", + "unit": "kilowattHour", + "value": [ + 32.3, 32.1, 0, 0, 0, 10.4, 205.7, 538.8, 830.5, 915.9, 839.5, + 560.4000000000001, 208.6 + ] + }, + "monthValueReadAt": { + "type": "string", + "value": "2025-10-05T05:14:16.246Z" + }, + "week": { + "type": "array", + "unit": "kilowattHour", + "value": [39.39999999999999, 22.7, 0, 0, 2.3] + }, + "weekValueReadAt": { + "type": "string", + "value": "2025-10-05T05:14:16.246Z" + }, + "year": { + "type": "array", + "unit": "kilowattHour", + "value": [2565.7, 3809.6] + }, + "yearValueReadAt": { + "type": "string", + "value": "2025-10-05T05:14:16.246Z" + } + }, + "timestamp": "2025-10-05T09:39:30.539Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.summary.cooling", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "currentDay": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "currentMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "currentYear": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastSevenDays": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastYear": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.summary.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.summary.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "currentDay": { + "type": "number", + "unit": "kilowattHour", + "value": 2.6 + }, + "currentMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 16 + }, + "currentYear": { + "type": "number", + "unit": "kilowattHour", + "value": 875.0999999999999 + }, + "lastMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 89.7 + }, + "lastSevenDays": { + "type": "number", + "unit": "kilowattHour", + "value": 24.5 + }, + "lastYear": { + "type": "number", + "unit": "kilowattHour", + "value": 1536.8 + } + }, + "timestamp": "2025-10-05T05:14:18.020Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.summary.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.summary.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "currentDay": { + "type": "number", + "unit": "kilowattHour", + "value": 4.6 + }, + "currentMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 32.3 + }, + "currentYear": { + "type": "number", + "unit": "kilowattHour", + "value": 2565.7 + }, + "lastMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 32.1 + }, + "lastSevenDays": { + "type": "number", + "unit": "kilowattHour", + "value": 39.4 + }, + "lastYear": { + "type": "number", + "unit": "kilowattHour", + "value": 3809.6 + } + }, + "timestamp": "2025-10-05T09:39:30.539Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.summary.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.total", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "day": { + "type": "array", + "unit": "kilowattHour", + "value": [ + 7.199999999999999, 12.6, 10.5, 8.399999999999999, 9.6, + 9.399999999999999, 6.199999999999999, 10 + ] + }, + "dayValueReadAt": { + "type": "string", + "value": "2025-10-05T05:14:16.233Z" + }, + "month": { + "type": "array", + "unit": "kilowattHour", + "value": [ + 48.3, 121.80000000000001, 28.1, 41.7, 31.5, 65.3, 282, 683, 1008.9, + 1130.1999999999998, 1049.5, 739.1000000000001, 337 + ] + }, + "monthValueReadAt": { + "type": "string", + "value": "2025-10-05T05:14:16.233Z" + }, + "week": { + "type": "array", + "unit": "kilowattHour", + "value": [ + 63.89999999999999, 55.400000000000006, 13.8, 22.4, + 14.600000000000001 + ] + }, + "weekValueReadAt": { + "type": "string", + "value": "2025-10-05T05:14:16.233Z" + }, + "year": { + "type": "array", + "unit": "kilowattHour", + "value": [3440.8, 5346.400000000001] + }, + "yearValueReadAt": { + "type": "string", + "value": "2025-10-05T05:14:16.233Z" + } + }, + "timestamp": "2025-10-05T09:39:30.539Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.total" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.primaryCircuit.fans.0.current", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "percent", + "value": 0 + } + }, + "timestamp": "2025-10-05T09:42:44.572Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.primaryCircuit.fans.0.current" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.primaryCircuit.fans.1.current", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "percent", + "value": 0 + } + }, + "timestamp": "2025-10-05T09:42:44.572Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.primaryCircuit.fans.1.current" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.primaryCircuit.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 13.9 + } + }, + "timestamp": "2025-10-05T09:52:33.577Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.primaryCircuit.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.primaryCircuit.valves.fourThreeWay", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.primaryCircuit.valves.fourThreeWay" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.spf.dhw", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.scop.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "", + "value": 3.8 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.scop.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.spf.heating", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.scop.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "", + "value": 3.9 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.scop.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.spf.total", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.scop.total", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "", + "value": 3.9 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.scop.total" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryCircuit.operation.state", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "currentValue": { + "type": "string", + "value": "standby" + }, + "targetValue": { + "type": "string", + "value": "standby" + } + }, + "timestamp": "2025-10-05T09:39:40.888Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryCircuit.operation.state" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryCircuit.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 32.9 + } + }, + "timestamp": "2025-10-05T10:03:11.852Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryCircuit.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryCircuit.temperature.return.minimum", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "celsius", + "value": 5 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryCircuit.temperature.return.minimum" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryCircuit.valves.fourThreeWay", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "current": { + "type": "number", + "unit": "percent", + "value": 52 + }, + "target": { + "type": "number", + "unit": "percent", + "value": 50 + } + }, + "timestamp": "2025-10-05T09:42:04.378Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryCircuit.valves.fourThreeWay" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "connectionType": { + "type": "string", + "value": "unknown" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.configuration.defrosting", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.configuration.defrosting" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.configuration.dhw", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.configuration.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.configuration.dhw.comfortEnsuring", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.configuration.dhw.comfortEnsuring" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.configuration.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.configuration.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.configuration.heating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.configuration.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.configuration.heating.comfortEnsuring", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.configuration.heating.comfortEnsuring" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.configuration.hygiene", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.configuration.hygiene" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.configuration.refrigerationCircuitExceeded", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.configuration.refrigerationCircuitExceeded" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.configuration.runtime", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.configuration.runtime" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.configuration.screedDrying", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.configuration.screedDrying" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.electricity.energyFactor", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.electricity.energyFactor" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.electricity.price.low", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.electricity.price.low" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.electricity.price.normal", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.electricity.price.normal" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.fossil.energyFactor", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.fossil.energyFactor" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.fossil.price.normal", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.fossil.price.normal" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.state", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.state" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.status", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.status" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.temperature.current", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.temperature.current" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.valves.threeWay", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.valves.threeWay" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.seer.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.seer.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.pressure.supply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "bar", + "value": 1.8 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.sensors.pressure.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.temperature.allengra", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 33.8 + } + }, + "timestamp": "2025-10-05T10:08:44.151Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.sensors.temperature.allengra" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.temperature.hydraulicSeparator", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T10:06:21.715Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.sensors.temperature.hydraulicSeparator" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.temperature.outside", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 12.2 + } + }, + "timestamp": "2025-10-05T09:35:59.944Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.sensors.temperature.outside" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.temperature.return", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 34.2 + } + }, + "timestamp": "2025-10-05T09:49:34.529Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.sensors.temperature.return" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.volumetricFlow.allengra", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "liter/hour", + "value": 0 + } + }, + "timestamp": "2025-10-05T09:42:08.629Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.sensors.volumetricFlow.allengra" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.spf.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "", + "value": 3.8 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.spf.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.spf.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "", + "value": 3.9 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.spf.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.spf.total", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "", + "value": 3.9 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.spf.total" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.valves.fourThreeWay.position", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "climatCircuitTwoDefrost" + } + }, + "timestamp": "2025-10-05T09:41:55.621Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.valves.fourThreeWay.position" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "tcu.wifi", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "strength": { + "type": "number", + "unit": "", + "value": -30 + } + }, + "timestamp": "2025-10-05T05:18:18.724Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/tcu.wifi" + } + ] +} diff --git a/tests/components/vicare/test_climate.py b/tests/components/vicare/test_climate.py index ac179456c659c4..28ae7ae6255480 100644 --- a/tests/components/vicare/test_climate.py +++ b/tests/components/vicare/test_climate.py @@ -6,7 +6,12 @@ from PyViCare.PyViCareUtils import PyViCareNotSupportedFeatureError from syrupy.assertion import SnapshotAssertion -from homeassistant.components.climate import ATTR_HVAC_ACTION, HVACAction +from homeassistant.components.climate import ( + ATTR_HVAC_ACTION, + ATTR_HVAC_MODES, + HVACAction, + HVACMode, +) from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_component, entity_registry as er @@ -165,3 +170,33 @@ async def test_hvac_action_multi_compressor_cooling_takes_precedence( assert climate_states, "no climate entity exposed hvac_action" for state in climate_states: assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING + + +async def test_hvac_mode_cooling( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """hvac_mode maps the ViCare cooling operating mode to HVACMode.COOL.""" + fixtures: list[Fixture] = [ + Fixture(set(), "vicare/Vitocal250A_cooling.json"), + ] + + with ( + patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid", + ), + patch( + f"{MODULE}._setup_vicare_api", + return_value=MockPyViCare(fixtures).as_vicare_data(), + ), + patch(f"{MODULE}.PLATFORMS", [Platform.CLIMATE]), + ): + await setup_integration(hass, mock_config_entry) + component: entity_component.EntityComponent = hass.data["climate"] + for entity in component.entities: + await entity.async_update_ha_state(force_refresh=True) + + state = hass.states.get("climate.model0_heating") + assert state is not None + assert state.state == HVACMode.COOL + assert HVACMode.COOL in state.attributes[ATTR_HVAC_MODES] diff --git a/tests/components/yardian/test_switch.py b/tests/components/yardian/test_switch.py index 07e826f6c10c3b..f71d1b0d246ada 100644 --- a/tests/components/yardian/test_switch.py +++ b/tests/components/yardian/test_switch.py @@ -68,4 +68,4 @@ async def test_turn_off_switch( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - mock_yardian_client.stop_irrigation.assert_called_once() + mock_yardian_client.stop_zone.assert_called_once() diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 85b0e2c54192a9..8088fb13ec3688 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -1187,6 +1187,23 @@ async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): assert "keep_old_devices" in entry.data +@pytest.mark.usefixtures("supervisor", "addon_info") +async def test_esphome_discovery_title_placeholders(hass: HomeAssistant) -> None: + """Test ESPHome discovery sets the name placeholder for the flow_title.""" + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ESPHOME}, + data=ESPHOME_DISCOVERY_INFO, + ) + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert ( + flows[0]["context"]["title_placeholders"]["name"] + == "Network 0x000004d2 via mock-name (ESPHome)" + ) + + @pytest.mark.parametrize( "service_info", [ESPHOME_DISCOVERY_INFO, ESPHOME_DISCOVERY_INFO_CLEAN] ) @@ -4087,9 +4104,10 @@ async def test_zeroconf(hass: HomeAssistant) -> None: flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 flow = flows[0] - assert flow["context"]["title_placeholders"]["host"] == "127.0.0.1" - assert flow["context"]["title_placeholders"]["port"] == "3000" - assert flow["context"]["title_placeholders"]["home_id"] == "0x000004d2" # 1234 + assert ( + flow["context"]["title_placeholders"]["name"] + == "Network 0x000004d2 at 127.0.0.1:3000" + ) with ( patch( diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index bf363d69560009..a7606984d00417 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -31,6 +31,12 @@ from tests.common import MockConfigEntry, async_mock_service +@pytest.fixture(autouse=True) +async def setup_llm(hass: HomeAssistant) -> None: + """Set up the llm integration so the Assist API can pull tools from it.""" + assert await async_setup_component(hass, "llm", {}) + + @pytest.fixture def llm_context() -> llm.LLMContext: """Return tool input context.""" @@ -142,22 +148,13 @@ async def test_call_tool_no_existing( async def test_assist_api( hass: HomeAssistant, - entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, area_registry: ar.AreaRegistry, floor_registry: fr.FloorRegistry, ) -> None: - """Test Assist API.""" + """Test calling an IntentTool through the Assist API.""" assert await async_setup_component(hass, "homeassistant", {}) - entity_registry.async_get_or_create( - "light", - "kitchen", - "mock-id-kitchen", - original_name="Kitchen", - suggested_object_id="kitchen", - ).write_unavailable_state(hass) - test_context = Context() llm_context = llm.LLMContext( platform="test_platform", @@ -176,32 +173,10 @@ async def test_assist_api( class MyIntentHandler(intent.IntentHandler): intent_type = "test_intent" slot_schema = schema - platforms = set() # Match none intent_handler = MyIntentHandler() - intent.async_register(hass, intent_handler) - - assert len(llm.async_get_apis(hass)) == 1 - api = await llm.async_get_api(hass, "assist", llm_context) - assert [tool.name for tool in api.tools] == ["GetDateTime", "GetLiveContext"] - - # Match all - intent_handler.platforms = None - - api = await llm.async_get_api(hass, "assist", llm_context) - assert [tool.name for tool in api.tools] == [ - "test_intent", - "GetDateTime", - "GetLiveContext", - ] - - # Match specific domain - intent_handler.platforms = {"light"} - - api = await llm.async_get_api(hass, "assist", llm_context) - assert len(api.tools) == 3 - tool = api.tools[0] + tool = llm.IntentTool("test_intent", intent_handler) assert tool.name == "test_intent" assert tool.description == "Execute Home Assistant test_intent intent" assert tool.parameters == vol.Schema( @@ -213,6 +188,14 @@ class MyIntentHandler(intent.IntentHandler): ) assert str(tool) == "" + api = next(api for api in llm.async_get_apis(hass) if api.id == "assist") + instance = llm.APIInstance( + api=api, + api_prompt="", + llm_context=llm_context, + tools=[tool], + ) + assert test_context.json_fragment # To reproduce an error case in tracing intent_response = intent.IntentResponse("*") intent_response.async_set_states( @@ -230,7 +213,7 @@ class MyIntentHandler(intent.IntentHandler): with patch( "homeassistant.helpers.intent.async_handle", return_value=intent_response ) as mock_intent_handle: - response = await api.async_call_tool(tool_input) + response = await instance.async_call_tool(tool_input) mock_intent_handle.assert_awaited_once_with( hass=hass, @@ -286,7 +269,7 @@ class MyIntentHandler(intent.IntentHandler): with patch( "homeassistant.helpers.intent.async_handle", return_value=intent_response ) as mock_intent_handle: - response = await api.async_call_tool(tool_input) + response = await instance.async_call_tool(tool_input) mock_intent_handle.assert_awaited_once_with( hass=hass, @@ -346,55 +329,16 @@ async def test_assist_api_get_timer_tools( assert "HassStartTimer" in [tool.name for tool in api.tools] -async def test_assist_api_tools( - hass: HomeAssistant, llm_context: llm.LLMContext -) -> None: - """Test getting timer tools with Assist API.""" - assert await async_setup_component(hass, "homeassistant", {}) - assert await async_setup_component(hass, "intent", {}) - - llm_context.device_id = "test_device" - - async_register_timer_handler(hass, "test_device", lambda *args: None) - - class MyIntentHandler(intent.IntentHandler): - intent_type = "Super crazy intent with unique nĂ¥me" - description = "my intent handler" - - intent.async_register(hass, MyIntentHandler()) - - api = await llm.async_get_api(hass, "assist", llm_context) - assert [tool.name for tool in api.tools] == [ - "HassTurnOn", - "HassTurnOff", - "HassStartTimer", - "HassCancelTimer", - "HassCancelAllTimers", - "HassIncreaseTimer", - "HassDecreaseTimer", - "HassPauseTimer", - "HassUnpauseTimer", - "HassTimerStatus", - "Super_crazy_intent_with_unique_name", - "GetDateTime", - ] - - async def test_assist_api_description( hass: HomeAssistant, llm_context: llm.LLMContext ) -> None: - """Test intent description with Assist API.""" + """Test that the intent handler description is used for the tool.""" class MyIntentHandler(intent.IntentHandler): intent_type = "test_intent" description = "my intent handler" - intent.async_register(hass, MyIntentHandler()) - - assert len(llm.async_get_apis(hass)) == 1 - api = await llm.async_get_api(hass, "assist", llm_context) - assert len(api.tools) == 2 - tool = api.tools[0] + tool = llm.IntentTool("test_intent", MyIntentHandler()) assert tool.name == "test_intent" assert tool.description == "my intent handler" @@ -767,9 +711,9 @@ def create_entity( ) api = await llm.async_get_api(hass, "assist", llm_context) assert api.api_prompt == ( - f"""{first_part_prompt} -{dynamic_context_prompt} + f"""{dynamic_context_prompt} {stateless_exposed_entities_prompt} +{first_part_prompt} {area_prompt} {no_timer_prompt}""" ) @@ -792,9 +736,9 @@ def create_entity( ) api = await llm.async_get_api(hass, "assist", llm_context) assert api.api_prompt == ( - f"""{first_part_prompt} -{dynamic_context_prompt} + f"""{dynamic_context_prompt} {stateless_exposed_entities_prompt} +{first_part_prompt} {area_prompt} {no_timer_prompt}""" ) @@ -809,9 +753,9 @@ def create_entity( ) api = await llm.async_get_api(hass, "assist", llm_context) assert api.api_prompt == ( - f"""{first_part_prompt} -{dynamic_context_prompt} + f"""{dynamic_context_prompt} {stateless_exposed_entities_prompt} +{first_part_prompt} {area_prompt} {no_timer_prompt}""" ) @@ -822,9 +766,9 @@ def create_entity( api = await llm.async_get_api(hass, "assist", llm_context) # The no_timer_prompt is gone assert api.api_prompt == ( - f"""{first_part_prompt} -{dynamic_context_prompt} + f"""{dynamic_context_prompt} {stateless_exposed_entities_prompt} +{first_part_prompt} {area_prompt}""" ) @@ -1232,7 +1176,7 @@ async def test_script_tool( api = await llm.async_get_api(hass, "assist", llm_context) - tools = [tool for tool in api.tools if isinstance(tool, llm.ScriptTool)] + tools = [tool for tool in api.tools if isinstance(tool, llm.ActionTool)] assert len(tools) == 2 tool = tools[0] @@ -1356,7 +1300,7 @@ async def test_script_tool( api = await llm.async_get_api(hass, "assist", llm_context) - tools = [tool for tool in api.tools if isinstance(tool, llm.ScriptTool)] + tools = [tool for tool in api.tools if isinstance(tool, llm.ActionTool)] assert len(tools) == 2 tool = tools[0] @@ -1412,7 +1356,7 @@ async def test_script_tool_name(hass: HomeAssistant) -> None: api = await llm.async_get_api(hass, "assist", llm_context) - tools = [tool for tool in api.tools if isinstance(tool, llm.ScriptTool)] + tools = [tool for tool in api.tools if isinstance(tool, llm.ActionTool)] assert len(tools) == 1 tool = tools[0] @@ -1697,6 +1641,7 @@ async def test_selector_serializer( async def test_calendar_get_events_tool(hass: HomeAssistant) -> None: """Test the calendar get events tool.""" assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "calendar", {}) hass.states.async_set( "calendar.test_calendar", "on", {"friendly_name": "Mock Calendar Name"} ) @@ -1953,7 +1898,8 @@ async def test_no_tools_exposed(hass: HomeAssistant) -> None: device_id=None, ) api = await llm.async_get_api(hass, "assist", llm_context) - assert [tool.name for tool in api.tools] == ["GetDateTime"] + # GetLiveContext is always offered; it reports when nothing is exposed. + assert [tool.name for tool in api.tools] == ["GetLiveContext", "GetDateTime"] async def test_merged_api(hass: HomeAssistant, llm_context: llm.LLMContext) -> None: diff --git a/tests/snapshots/test_bootstrap.ambr b/tests/snapshots/test_bootstrap.ambr index e50a244d789523..561c4a060845ca 100644 --- a/tests/snapshots/test_bootstrap.ambr +++ b/tests/snapshots/test_bootstrap.ambr @@ -61,6 +61,7 @@ 'labs', 'lawn_mower', 'light', + 'llm', 'lock', 'logger', 'lovelace', @@ -169,6 +170,7 @@ 'labs', 'lawn_mower', 'light', + 'llm', 'lock', 'logger', 'lovelace',