diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ded82ae564fbc3..ba68414b30ada7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -247,17 +247,11 @@ jobs: && github.event.inputs.audit-licenses-only != 'true' steps: - *checkout - - name: Register yamllint problem matcher + - name: Register problem matchers run: | echo "::add-matcher::.github/workflows/matchers/yamllint.json" - - name: Register check-json problem matcher - run: | echo "::add-matcher::.github/workflows/matchers/check-json.json" - - name: Register check executables problem matcher - run: | echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json" - - name: Register codespell problem matcher - run: | echo "::add-matcher::.github/workflows/matchers/codespell.json" - name: Run prek uses: j178/prek-action@9d6a3097e0c1865ecce00cfb89fe80f2ee91b547 # v1.0.12 diff --git a/.github/workflows/matchers/check-executables-have-shebangs.json b/.github/workflows/matchers/check-executables-have-shebangs.json index 667ef7956328de..1ff6ae0e94c6a4 100644 --- a/.github/workflows/matchers/check-executables-have-shebangs.json +++ b/.github/workflows/matchers/check-executables-have-shebangs.json @@ -4,7 +4,7 @@ "owner": "check-executables-have-shebangs", "pattern": [ { - "regexp": "^(.+):\\s(.+)$", + "regexp": "^(.+):\\s(marked executable but has no \\(or invalid\\) shebang!.*)$", "file": 1, "message": 2 } diff --git a/homeassistant/components/assist_satellite/condition.py b/homeassistant/components/assist_satellite/condition.py new file mode 100644 index 00000000000000..0c0a402d6f51bd --- /dev/null +++ b/homeassistant/components/assist_satellite/condition.py @@ -0,0 +1,23 @@ +"""Provides conditions for assist satellites.""" + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.condition import Condition, make_entity_state_condition + +from .const import DOMAIN +from .entity import AssistSatelliteState + +CONDITIONS: dict[str, type[Condition]] = { + "is_idle": make_entity_state_condition(DOMAIN, AssistSatelliteState.IDLE), + "is_listening": make_entity_state_condition(DOMAIN, AssistSatelliteState.LISTENING), + "is_processing": make_entity_state_condition( + DOMAIN, AssistSatelliteState.PROCESSING + ), + "is_responding": make_entity_state_condition( + DOMAIN, AssistSatelliteState.RESPONDING + ), +} + + +async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + """Return the assist satellite conditions.""" + return CONDITIONS diff --git a/homeassistant/components/assist_satellite/conditions.yaml b/homeassistant/components/assist_satellite/conditions.yaml new file mode 100644 index 00000000000000..eeb7f02b913cd3 --- /dev/null +++ b/homeassistant/components/assist_satellite/conditions.yaml @@ -0,0 +1,19 @@ +.condition_common: &condition_common + target: + entity: + domain: assist_satellite + fields: + behavior: + required: true + default: any + selector: + select: + translation_key: condition_behavior + options: + - all + - any + +is_idle: *condition_common +is_listening: *condition_common +is_processing: *condition_common +is_responding: *condition_common diff --git a/homeassistant/components/assist_satellite/icons.json b/homeassistant/components/assist_satellite/icons.json index 975b943416d1ff..c4f15d320deaa6 100644 --- a/homeassistant/components/assist_satellite/icons.json +++ b/homeassistant/components/assist_satellite/icons.json @@ -1,4 +1,18 @@ { + "conditions": { + "is_idle": { + "condition": "mdi:chat-sleep" + }, + "is_listening": { + "condition": "mdi:chat-question" + }, + "is_processing": { + "condition": "mdi:chat-processing" + }, + "is_responding": { + "condition": "mdi:chat-alert" + } + }, "entity_component": { "_": { "default": "mdi:account-voice" diff --git a/homeassistant/components/assist_satellite/strings.json b/homeassistant/components/assist_satellite/strings.json index 95ce20a851cb8f..4680df87f33625 100644 --- a/homeassistant/components/assist_satellite/strings.json +++ b/homeassistant/components/assist_satellite/strings.json @@ -1,8 +1,52 @@ { "common": { + "condition_behavior_description": "How the state should match on the targeted Assist satellites.", + "condition_behavior_name": "Behavior", "trigger_behavior_description": "The behavior of the targeted Assist satellites to trigger on.", "trigger_behavior_name": "Behavior" }, + "conditions": { + "is_idle": { + "description": "Tests if one or more Assist satellites are idle.", + "fields": { + "behavior": { + "description": "[%key:component::assist_satellite::common::condition_behavior_description%]", + "name": "[%key:component::assist_satellite::common::condition_behavior_name%]" + } + }, + "name": "If a satellite is idle" + }, + "is_listening": { + "description": "Tests if one or more Assist satellites are listening.", + "fields": { + "behavior": { + "description": "[%key:component::assist_satellite::common::condition_behavior_description%]", + "name": "[%key:component::assist_satellite::common::condition_behavior_name%]" + } + }, + "name": "If a satellite is listening" + }, + "is_processing": { + "description": "Tests if one or more Assist satellites are processing.", + "fields": { + "behavior": { + "description": "[%key:component::assist_satellite::common::condition_behavior_description%]", + "name": "[%key:component::assist_satellite::common::condition_behavior_name%]" + } + }, + "name": "If a satellite is processing" + }, + "is_responding": { + "description": "Tests if one or more Assist satellites are responding.", + "fields": { + "behavior": { + "description": "[%key:component::assist_satellite::common::condition_behavior_description%]", + "name": "[%key:component::assist_satellite::common::condition_behavior_name%]" + } + }, + "name": "If a satellite is responding" + } + }, "entity_component": { "_": { "name": "Assist satellite", @@ -21,6 +65,12 @@ "sentences": "Sentences" } }, + "condition_behavior": { + "options": { + "all": "All", + "any": "Any" + } + }, "trigger_behavior": { "options": { "any": "Any", diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 34097bb989ba68..4835d9149d4315 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -124,6 +124,7 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = { "alarm_control_panel", + "assist_satellite", "fan", "light", } diff --git a/homeassistant/components/concord232/alarm_control_panel.py b/homeassistant/components/concord232/alarm_control_panel.py index 61cf2aebb31f41..f4498c43ab6797 100644 --- a/homeassistant/components/concord232/alarm_control_panel.py +++ b/homeassistant/components/concord232/alarm_control_panel.py @@ -49,11 +49,11 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Concord232 alarm control panel platform.""" - name = config[CONF_NAME] - code = config.get(CONF_CODE) - mode = config[CONF_MODE] - host = config[CONF_HOST] - port = config[CONF_PORT] + name: str = config[CONF_NAME] + code: str | None = config.get(CONF_CODE) + mode: str = config[CONF_MODE] + host: str = config[CONF_HOST] + port: int = config[CONF_PORT] url = f"http://{host}:{port}" @@ -72,7 +72,7 @@ class Concord232Alarm(AlarmControlPanelEntity): | AlarmControlPanelEntityFeature.ARM_AWAY ) - def __init__(self, url, name, code, mode): + def __init__(self, url: str, name: str, code: str | None, mode: str) -> None: """Initialize the Concord232 alarm panel.""" self._attr_name = name @@ -125,7 +125,7 @@ def alarm_arm_away(self, code: str | None = None) -> None: return self._alarm.arm("away") - def _validate_code(self, code, state): + def _validate_code(self, code: str | None, state: AlarmControlPanelState) -> bool: """Validate given code.""" if self._code is None: return True diff --git a/homeassistant/components/concord232/binary_sensor.py b/homeassistant/components/concord232/binary_sensor.py index 4eee5bd2d47c08..cc4d3bb92bdb3c 100644 --- a/homeassistant/components/concord232/binary_sensor.py +++ b/homeassistant/components/concord232/binary_sensor.py @@ -4,6 +4,7 @@ import datetime import logging +from typing import Any from concord232 import client as concord232_client import requests @@ -29,8 +30,7 @@ DEFAULT_HOST = "localhost" DEFAULT_NAME = "Alarm" -DEFAULT_PORT = "5007" -DEFAULT_SSL = False +DEFAULT_PORT = 5007 SCAN_INTERVAL = datetime.timedelta(seconds=10) @@ -56,10 +56,10 @@ def setup_platform( ) -> None: """Set up the Concord232 binary sensor platform.""" - host = config[CONF_HOST] - port = config[CONF_PORT] - exclude = config[CONF_EXCLUDE_ZONES] - zone_types = config[CONF_ZONE_TYPES] + host: str = config[CONF_HOST] + port: int = config[CONF_PORT] + exclude: list[int] = config[CONF_EXCLUDE_ZONES] + zone_types: dict[int, BinarySensorDeviceClass] = config[CONF_ZONE_TYPES] sensors = [] try: @@ -84,7 +84,6 @@ def setup_platform( if zone["number"] not in exclude: sensors.append( Concord232ZoneSensor( - hass, client, zone, zone_types.get(zone["number"], get_opening_type(zone)), @@ -110,26 +109,25 @@ def get_opening_type(zone): class Concord232ZoneSensor(BinarySensorEntity): """Representation of a Concord232 zone as a sensor.""" - def __init__(self, hass, client, zone, zone_type): + def __init__( + self, + client: concord232_client.Client, + zone: dict[str, Any], + zone_type: BinarySensorDeviceClass, + ) -> None: """Initialize the Concord232 binary sensor.""" - self._hass = hass self._client = client self._zone = zone self._number = zone["number"] - self._zone_type = zone_type + self._attr_device_class = zone_type @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this sensor, from DEVICE_CLASSES.""" - return self._zone_type - - @property - def name(self): + def name(self) -> str: """Return the name of the binary sensor.""" return self._zone["name"] @property - def is_on(self): + def is_on(self) -> bool: """Return true if the binary sensor is on.""" # True means "faulted" or "open" or "abnormal state" return bool(self._zone["state"] != "Normal") @@ -145,5 +143,5 @@ def update(self) -> None: if hasattr(self._client, "zones"): self._zone = next( - (x for x in self._client.zones if x["number"] == self._number), None + x for x in self._client.zones if x["number"] == self._number ) diff --git a/homeassistant/components/digital_ocean/__init__.py b/homeassistant/components/digital_ocean/__init__.py index 306ddc8e9a5ab6..b4bd6ab1b923d0 100644 --- a/homeassistant/components/digital_ocean/__init__.py +++ b/homeassistant/components/digital_ocean/__init__.py @@ -1,6 +1,7 @@ """Support for Digital Ocean.""" -from datetime import timedelta +from __future__ import annotations + import logging import digitalocean @@ -12,27 +13,12 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle -_LOGGER = logging.getLogger(__name__) - -ATTR_CREATED_AT = "created_at" -ATTR_DROPLET_ID = "droplet_id" -ATTR_DROPLET_NAME = "droplet_name" -ATTR_FEATURES = "features" -ATTR_IPV4_ADDRESS = "ipv4_address" -ATTR_IPV6_ADDRESS = "ipv6_address" -ATTR_MEMORY = "memory" -ATTR_REGION = "region" -ATTR_VCPUS = "vcpus" +from .const import DATA_DIGITAL_OCEAN, DOMAIN, MIN_TIME_BETWEEN_UPDATES -ATTRIBUTION = "Data provided by Digital Ocean" +_LOGGER = logging.getLogger(__name__) -CONF_DROPLETS = "droplets" -DATA_DIGITAL_OCEAN = "data_do" DIGITAL_OCEAN_PLATFORMS = [Platform.SWITCH, Platform.BINARY_SENSOR] -DOMAIN = "digital_ocean" - -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) CONFIG_SCHEMA = vol.Schema( {DOMAIN: vol.Schema({vol.Required(CONF_ACCESS_TOKEN): cv.string})}, diff --git a/homeassistant/components/digital_ocean/binary_sensor.py b/homeassistant/components/digital_ocean/binary_sensor.py index b0041f5220b9c5..6439a97ade8cc2 100644 --- a/homeassistant/components/digital_ocean/binary_sensor.py +++ b/homeassistant/components/digital_ocean/binary_sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from typing import Any import voluptuous as vol @@ -16,7 +17,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import ( +from .const import ( ATTR_CREATED_AT, ATTR_DROPLET_ID, ATTR_DROPLET_NAME, @@ -65,6 +66,7 @@ class DigitalOceanBinarySensor(BinarySensorEntity): """Representation of a Digital Ocean droplet sensor.""" _attr_attribution = ATTRIBUTION + _attr_device_class = BinarySensorDeviceClass.MOVING def __init__(self, do, droplet_id): """Initialize a new Digital Ocean sensor.""" @@ -79,17 +81,12 @@ def name(self): return self.data.name @property - def is_on(self): + def is_on(self) -> bool: """Return true if the binary sensor is on.""" return self.data.status == "active" @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this sensor.""" - return BinarySensorDeviceClass.MOVING - - @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the Digital Ocean droplet.""" return { ATTR_CREATED_AT: self.data.created_at, diff --git a/homeassistant/components/digital_ocean/const.py b/homeassistant/components/digital_ocean/const.py new file mode 100644 index 00000000000000..77dfb1bf4e2735 --- /dev/null +++ b/homeassistant/components/digital_ocean/const.py @@ -0,0 +1,30 @@ +"""Support for Digital Ocean.""" + +from __future__ import annotations + +from datetime import timedelta +from typing import TYPE_CHECKING + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from . import DigitalOcean + +ATTR_CREATED_AT = "created_at" +ATTR_DROPLET_ID = "droplet_id" +ATTR_DROPLET_NAME = "droplet_name" +ATTR_FEATURES = "features" +ATTR_IPV4_ADDRESS = "ipv4_address" +ATTR_IPV6_ADDRESS = "ipv6_address" +ATTR_MEMORY = "memory" +ATTR_REGION = "region" +ATTR_VCPUS = "vcpus" + +ATTRIBUTION = "Data provided by Digital Ocean" + +CONF_DROPLETS = "droplets" + +DOMAIN = "digital_ocean" +DATA_DIGITAL_OCEAN: HassKey[DigitalOcean] = HassKey(DOMAIN) + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) diff --git a/homeassistant/components/digital_ocean/switch.py b/homeassistant/components/digital_ocean/switch.py index 409fa63c1c299d..a3e6b4f95bf23f 100644 --- a/homeassistant/components/digital_ocean/switch.py +++ b/homeassistant/components/digital_ocean/switch.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import ( +from .const import ( ATTR_CREATED_AT, ATTR_DROPLET_ID, ATTR_DROPLET_NAME, @@ -80,12 +80,12 @@ def name(self): return self.data.name @property - def is_on(self): + def is_on(self) -> bool: """Return true if switch is on.""" return self.data.status == "active" @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the Digital Ocean droplet.""" return { ATTR_CREATED_AT: self.data.created_at, diff --git a/homeassistant/components/egardia/__init__.py b/homeassistant/components/egardia/__init__.py index eb6b4cd49d8127..dc94bb79a4e0f7 100644 --- a/homeassistant/components/egardia/__init__.py +++ b/homeassistant/components/egardia/__init__.py @@ -18,6 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType +from homeassistant.util.hass_dict import HassKey _LOGGER = logging.getLogger(__name__) @@ -35,7 +36,7 @@ DEFAULT_VERSION = "GATE-01" DOMAIN = "egardia" -EGARDIA_DEVICE = "egardiadevice" +EGARDIA_DEVICE: HassKey[egardiadevice.EgardiaDevice] = HassKey(DOMAIN) EGARDIA_NAME = "egardianame" EGARDIA_REPORT_SERVER_CODES = "egardia_rs_codes" EGARDIA_REPORT_SERVER_ENABLED = "egardia_rs_enabled" diff --git a/homeassistant/components/egardia/alarm_control_panel.py b/homeassistant/components/egardia/alarm_control_panel.py index 5a18a23541a13a..9ebe8c1704eb03 100644 --- a/homeassistant/components/egardia/alarm_control_panel.py +++ b/homeassistant/components/egardia/alarm_control_panel.py @@ -4,6 +4,7 @@ import logging +from pythonegardia.egardiadevice import EgardiaDevice import requests from homeassistant.components.alarm_control_panel import ( @@ -11,6 +12,7 @@ AlarmControlPanelEntityFeature, AlarmControlPanelState, ) +from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -47,10 +49,10 @@ def setup_platform( if discovery_info is None: return device = EgardiaAlarm( - discovery_info["name"], + discovery_info[CONF_NAME], hass.data[EGARDIA_DEVICE], discovery_info[CONF_REPORT_SERVER_ENABLED], - discovery_info.get(CONF_REPORT_SERVER_CODES), + discovery_info[CONF_REPORT_SERVER_CODES], discovery_info[CONF_REPORT_SERVER_PORT], ) @@ -67,8 +69,13 @@ class EgardiaAlarm(AlarmControlPanelEntity): ) def __init__( - self, name, egardiasystem, rs_enabled=False, rs_codes=None, rs_port=52010 - ): + self, + name: str, + egardiasystem: EgardiaDevice, + rs_enabled: bool, + rs_codes: dict[str, list[str]], + rs_port: int, + ) -> None: """Initialize the Egardia alarm.""" self._attr_name = name self._egardiasystem = egardiasystem @@ -85,9 +92,7 @@ async def async_added_to_hass(self) -> None: @property def should_poll(self) -> bool: """Poll if no report server is enabled.""" - if not self._rs_enabled: - return True - return False + return not self._rs_enabled def handle_status_event(self, event): """Handle the Egardia system status event.""" diff --git a/homeassistant/components/egardia/binary_sensor.py b/homeassistant/components/egardia/binary_sensor.py index 8124b5516f8fb6..9c778cdad5af47 100644 --- a/homeassistant/components/egardia/binary_sensor.py +++ b/homeassistant/components/egardia/binary_sensor.py @@ -2,11 +2,12 @@ from __future__ import annotations +from pythonegardia.egardiadevice import EgardiaDevice + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -51,30 +52,20 @@ async def async_setup_platform( class EgardiaBinarySensor(BinarySensorEntity): """Represents a sensor based on an Egardia sensor (IR, Door Contact).""" - def __init__(self, sensor_id, name, egardia_system, device_class): + def __init__( + self, + sensor_id: str, + name: str, + egardia_system: EgardiaDevice, + device_class: BinarySensorDeviceClass | None, + ) -> None: """Initialize the sensor device.""" self._id = sensor_id - self._name = name - self._state = None - self._device_class = device_class + self._attr_name = name + self._attr_device_class = device_class self._egardia_system = egardia_system def update(self) -> None: """Update the status.""" egardia_input = self._egardia_system.getsensorstate(self._id) - self._state = STATE_ON if egardia_input else STATE_OFF - - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def is_on(self): - """Whether the device is switched on.""" - return self._state == STATE_ON - - @property - def device_class(self) -> BinarySensorDeviceClass | None: - """Return the device class.""" - return self._device_class + self._attr_is_on = bool(egardia_input) diff --git a/homeassistant/components/envisalink/__init__.py b/homeassistant/components/envisalink/__init__.py index 919704a67281a2..ee5468ddd817e2 100644 --- a/homeassistant/components/envisalink/__init__.py +++ b/homeassistant/components/envisalink/__init__.py @@ -18,12 +18,13 @@ from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType +from homeassistant.util.hass_dict import HassKey _LOGGER = logging.getLogger(__name__) DOMAIN = "envisalink" -DATA_EVL = "envisalink" +DATA_EVL: HassKey[EnvisalinkAlarmPanel] = HassKey(DOMAIN) CONF_EVL_KEEPALIVE = "keepalive_interval" CONF_EVL_PORT = "port" diff --git a/homeassistant/components/envisalink/alarm_control_panel.py b/homeassistant/components/envisalink/alarm_control_panel.py index 9d1b6d0d7a16e0..c1cee5198f28ec 100644 --- a/homeassistant/components/envisalink/alarm_control_panel.py +++ b/homeassistant/components/envisalink/alarm_control_panel.py @@ -3,7 +3,9 @@ from __future__ import annotations import logging +from typing import Any +from pyenvisalink import EnvisalinkAlarmPanel import voluptuous as vol from homeassistant.components.alarm_control_panel import ( @@ -22,6 +24,7 @@ from . import ( CONF_PANIC, CONF_PARTITIONNAME, + CONF_PARTITIONS, DATA_EVL, DOMAIN, PARTITION_SCHEMA, @@ -51,15 +54,14 @@ async def async_setup_platform( """Perform the setup for Envisalink alarm panels.""" if not discovery_info: return - configured_partitions = discovery_info["partitions"] - code = discovery_info[CONF_CODE] - panic_type = discovery_info[CONF_PANIC] + configured_partitions: dict[int, dict[str, Any]] = discovery_info[CONF_PARTITIONS] + code: str | None = discovery_info[CONF_CODE] + panic_type: str = discovery_info[CONF_PANIC] entities = [] - for part_num in configured_partitions: - entity_config_data = PARTITION_SCHEMA(configured_partitions[part_num]) + for part_num, part_config in configured_partitions.items(): + entity_config_data = PARTITION_SCHEMA(part_config) entity = EnvisalinkAlarm( - hass, part_num, entity_config_data[CONF_PARTITIONNAME], code, @@ -103,8 +105,14 @@ class EnvisalinkAlarm(EnvisalinkEntity, AlarmControlPanelEntity): ) def __init__( - self, hass, partition_number, alarm_name, code, panic_type, info, controller - ): + self, + partition_number: int, + alarm_name: str, + code: str | None, + panic_type: str, + info: dict[str, Any], + controller: EnvisalinkAlarmPanel, + ) -> None: """Initialize the alarm panel.""" self._partition_number = partition_number self._panic_type = panic_type diff --git a/homeassistant/components/envisalink/binary_sensor.py b/homeassistant/components/envisalink/binary_sensor.py index 4b548ddb57295d..aa91731216fcfa 100644 --- a/homeassistant/components/envisalink/binary_sensor.py +++ b/homeassistant/components/envisalink/binary_sensor.py @@ -4,6 +4,9 @@ import datetime import logging +from typing import Any + +from pyenvisalink import EnvisalinkAlarmPanel from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -16,7 +19,14 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util -from . import CONF_ZONENAME, CONF_ZONETYPE, DATA_EVL, SIGNAL_ZONE_UPDATE, ZONE_SCHEMA +from . import ( + CONF_ZONENAME, + CONF_ZONES, + CONF_ZONETYPE, + DATA_EVL, + SIGNAL_ZONE_UPDATE, + ZONE_SCHEMA, +) from .entity import EnvisalinkEntity _LOGGER = logging.getLogger(__name__) @@ -31,13 +41,12 @@ async def async_setup_platform( """Set up the Envisalink binary sensor entities.""" if not discovery_info: return - configured_zones = discovery_info["zones"] + configured_zones: dict[int, dict[str, Any]] = discovery_info[CONF_ZONES] entities = [] - for zone_num in configured_zones: - entity_config_data = ZONE_SCHEMA(configured_zones[zone_num]) + for zone_num, zone_data in configured_zones.items(): + entity_config_data = ZONE_SCHEMA(zone_data) entity = EnvisalinkBinarySensor( - hass, zone_num, entity_config_data[CONF_ZONENAME], entity_config_data[CONF_ZONETYPE], @@ -52,9 +61,16 @@ async def async_setup_platform( class EnvisalinkBinarySensor(EnvisalinkEntity, BinarySensorEntity): """Representation of an Envisalink binary sensor.""" - def __init__(self, hass, zone_number, zone_name, zone_type, info, controller): + def __init__( + self, + zone_number: int, + zone_name: str, + zone_type: BinarySensorDeviceClass, + info: dict[str, Any], + controller: EnvisalinkAlarmPanel, + ) -> None: """Initialize the binary_sensor.""" - self._zone_type = zone_type + self._attr_device_class = zone_type self._zone_number = zone_number _LOGGER.debug("Setting up zone: %s", zone_name) @@ -69,9 +85,9 @@ async def async_added_to_hass(self) -> None: ) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" - attr = {} + attr: dict[str, Any] = {} # The Envisalink library returns a "last_fault" value that's the # number of seconds since the last fault, up to a maximum of 327680 @@ -104,11 +120,6 @@ def is_on(self): """Return true if sensor is on.""" return self._info["status"]["open"] - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this sensor, from DEVICE_CLASSES.""" - return self._zone_type - @callback def async_update_callback(self, zone): """Update the zone's state, if needed.""" diff --git a/homeassistant/components/envisalink/entity.py b/homeassistant/components/envisalink/entity.py index a686ed2e3cbd61..6327ecee4e964b 100644 --- a/homeassistant/components/envisalink/entity.py +++ b/homeassistant/components/envisalink/entity.py @@ -1,5 +1,9 @@ """Support for Envisalink devices.""" +from typing import Any + +from pyenvisalink import EnvisalinkAlarmPanel + from homeassistant.helpers.entity import Entity @@ -8,13 +12,10 @@ class EnvisalinkEntity(Entity): _attr_should_poll = False - def __init__(self, name, info, controller): + def __init__( + self, name: str, info: dict[str, Any], controller: EnvisalinkAlarmPanel + ) -> None: """Initialize the device.""" self._controller = controller self._info = info - self._name = name - - @property - def name(self): - """Return the name of the device.""" - return self._name + self._attr_name = name diff --git a/homeassistant/components/envisalink/sensor.py b/homeassistant/components/envisalink/sensor.py index 70d471a685cc05..d9b9ccab1640c4 100644 --- a/homeassistant/components/envisalink/sensor.py +++ b/homeassistant/components/envisalink/sensor.py @@ -3,6 +3,9 @@ from __future__ import annotations import logging +from typing import Any + +from pyenvisalink import EnvisalinkAlarmPanel from homeassistant.components.sensor import SensorEntity from homeassistant.core import HomeAssistant, callback @@ -12,6 +15,7 @@ from . import ( CONF_PARTITIONNAME, + CONF_PARTITIONS, DATA_EVL, PARTITION_SCHEMA, SIGNAL_KEYPAD_UPDATE, @@ -31,13 +35,12 @@ async def async_setup_platform( """Perform the setup for Envisalink sensor entities.""" if not discovery_info: return - configured_partitions = discovery_info["partitions"] + configured_partitions: dict[int, dict[str, Any]] = discovery_info[CONF_PARTITIONS] entities = [] - for part_num in configured_partitions: - entity_config_data = PARTITION_SCHEMA(configured_partitions[part_num]) + for part_num, part_config in configured_partitions.items(): + entity_config_data = PARTITION_SCHEMA(part_config) entity = EnvisalinkSensor( - hass, entity_config_data[CONF_PARTITIONNAME], part_num, hass.data[DATA_EVL].alarm_state["partition"][part_num], @@ -52,9 +55,16 @@ async def async_setup_platform( class EnvisalinkSensor(EnvisalinkEntity, SensorEntity): """Representation of an Envisalink keypad.""" - def __init__(self, hass, partition_name, partition_number, info, controller): + _attr_icon = "mdi:alarm" + + def __init__( + self, + partition_name: str, + partition_number: int, + info: dict[str, Any], + controller: EnvisalinkAlarmPanel, + ) -> None: """Initialize the sensor.""" - self._icon = "mdi:alarm" self._partition_number = partition_number _LOGGER.debug("Setting up sensor for partition: %s", partition_name) @@ -73,11 +83,6 @@ async def async_added_to_hass(self) -> None: ) ) - @property - def icon(self): - """Return the icon if any.""" - return self._icon - @property def native_value(self): """Return the overall state.""" diff --git a/homeassistant/components/envisalink/switch.py b/homeassistant/components/envisalink/switch.py index e4f37bf328d50e..81ecf8d8789762 100644 --- a/homeassistant/components/envisalink/switch.py +++ b/homeassistant/components/envisalink/switch.py @@ -5,13 +5,21 @@ import logging from typing import Any +from pyenvisalink import EnvisalinkAlarmPanel + from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import CONF_ZONENAME, DATA_EVL, SIGNAL_ZONE_BYPASS_UPDATE, ZONE_SCHEMA +from . import ( + CONF_ZONENAME, + CONF_ZONES, + DATA_EVL, + SIGNAL_ZONE_BYPASS_UPDATE, + ZONE_SCHEMA, +) from .entity import EnvisalinkEntity _LOGGER = logging.getLogger(__name__) @@ -26,16 +34,15 @@ async def async_setup_platform( """Set up the Envisalink switch entities.""" if not discovery_info: return - configured_zones = discovery_info["zones"] + configured_zones: dict[int, dict[str, Any]] = discovery_info[CONF_ZONES] entities = [] - for zone_num in configured_zones: - entity_config_data = ZONE_SCHEMA(configured_zones[zone_num]) + for zone_num, zone_data in configured_zones.items(): + entity_config_data = ZONE_SCHEMA(zone_data) zone_name = f"{entity_config_data[CONF_ZONENAME]}_bypass" _LOGGER.debug("Setting up zone_bypass switch: %s", zone_name) entity = EnvisalinkSwitch( - hass, zone_num, zone_name, hass.data[DATA_EVL].alarm_state["zone"][zone_num], @@ -49,7 +56,13 @@ async def async_setup_platform( class EnvisalinkSwitch(EnvisalinkEntity, SwitchEntity): """Representation of an Envisalink switch.""" - def __init__(self, hass, zone_number, zone_name, info, controller): + def __init__( + self, + zone_number: int, + zone_name: str, + info: dict[str, Any], + controller: EnvisalinkAlarmPanel, + ) -> None: """Initialize the switch.""" self._zone_number = zone_number diff --git a/homeassistant/components/homematic/binary_sensor.py b/homeassistant/components/homematic/binary_sensor.py index 4d8b4178f5406f..e2090b74ce8739 100644 --- a/homeassistant/components/homematic/binary_sensor.py +++ b/homeassistant/components/homematic/binary_sensor.py @@ -59,7 +59,7 @@ class HMBinarySensor(HMDevice, BinarySensorEntity): """Representation of a binary HomeMatic device.""" @property - def is_on(self): + def is_on(self) -> bool: """Return true if switch is on.""" if not self.available: return False @@ -73,7 +73,7 @@ def device_class(self) -> BinarySensorDeviceClass | None: return BinarySensorDeviceClass.MOTION return SENSOR_TYPES_CLASS.get(self._hmdevice.__class__.__name__) - def _init_data_struct(self): + def _init_data_struct(self) -> None: """Generate the data dictionary (self._data) from metadata.""" # Add state to data struct if self._state: @@ -86,11 +86,11 @@ class HMBatterySensor(HMDevice, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.BATTERY @property - def is_on(self): + def is_on(self) -> bool: """Return True if battery is low.""" return bool(self._hm_get_state()) - def _init_data_struct(self): + def _init_data_struct(self) -> None: """Generate the data dictionary (self._data) from metadata.""" # Add state to data struct if self._state: diff --git a/homeassistant/components/homematic/climate.py b/homeassistant/components/homematic/climate.py index 28943774b6ce74..096ad76db11673 100644 --- a/homeassistant/components/homematic/climate.py +++ b/homeassistant/components/homematic/climate.py @@ -178,7 +178,7 @@ def _hm_control_mode(self): # Homematic return self._data.get("CONTROL_MODE") - def _init_data_struct(self): + def _init_data_struct(self) -> None: """Generate a data dict (self._data) from the Homematic metadata.""" self._state = next(iter(self._hmdevice.WRITENODE.keys())) self._data[self._state] = None diff --git a/homeassistant/components/homematic/cover.py b/homeassistant/components/homematic/cover.py index b9f4a4fa96ab97..f93d92eed5675b 100644 --- a/homeassistant/components/homematic/cover.py +++ b/homeassistant/components/homematic/cover.py @@ -78,7 +78,7 @@ def stop_cover(self, **kwargs: Any) -> None: """Stop the device if in motion.""" self._hmdevice.stop(self._channel) - def _init_data_struct(self): + def _init_data_struct(self) -> None: """Generate a data dictionary (self._data) from metadata.""" self._state = "LEVEL" self._data.update({self._state: None}) @@ -138,7 +138,7 @@ def is_closed(self) -> bool: """Return whether the cover is closed.""" return self._hmdevice.is_closed(self._hm_get_state()) - def _init_data_struct(self): + def _init_data_struct(self) -> None: """Generate a data dictionary (self._data) from metadata.""" self._state = "DOOR_STATE" self._data.update({self._state: None}) diff --git a/homeassistant/components/homematic/entity.py b/homeassistant/components/homematic/entity.py index 3b5d2ebb509fc3..3e4d6a6fc71541 100644 --- a/homeassistant/components/homematic/entity.py +++ b/homeassistant/components/homematic/entity.py @@ -204,7 +204,7 @@ def _init_data(self): self._init_data_struct() @abstractmethod - def _init_data_struct(self): + def _init_data_struct(self) -> None: """Generate a data dictionary from the HomeMatic device metadata.""" diff --git a/homeassistant/components/homematic/light.py b/homeassistant/components/homematic/light.py index 838cdc9c3c3f09..62ce1cc9457b27 100644 --- a/homeassistant/components/homematic/light.py +++ b/homeassistant/components/homematic/light.py @@ -51,7 +51,7 @@ class HMLight(HMDevice, LightEntity): _attr_max_color_temp_kelvin = 6500 # 153 Mireds @property - def brightness(self): + def brightness(self) -> int | None: """Return the brightness of this light between 0..255.""" # Is dimmer? if self._state == "LEVEL": @@ -59,7 +59,7 @@ def brightness(self): return None @property - def is_on(self): + def is_on(self) -> bool: """Return true if light is on.""" try: return self._hm_get_state() > 0 @@ -98,7 +98,7 @@ def supported_features(self) -> LightEntityFeature: return features @property - def hs_color(self): + def hs_color(self) -> tuple[float, float] | None: """Return the hue and saturation color value [float, float].""" if ColorMode.HS not in self.supported_color_modes: return None @@ -116,14 +116,14 @@ def color_temp_kelvin(self) -> int | None: ) @property - def effect_list(self): + def effect_list(self) -> list[str] | None: """Return the list of supported effects.""" if not self.supported_features & LightEntityFeature.EFFECT: return None return self._hmdevice.get_effect_list() @property - def effect(self): + def effect(self) -> str | None: """Return the current color change program of the light.""" if not self.supported_features & LightEntityFeature.EFFECT: return None @@ -166,7 +166,7 @@ def turn_off(self, **kwargs: Any) -> None: self._hmdevice.off(self._channel) - def _init_data_struct(self): + def _init_data_struct(self) -> None: """Generate a data dict (self._data) from the Homematic metadata.""" # Use LEVEL self._state = "LEVEL" diff --git a/homeassistant/components/homematic/lock.py b/homeassistant/components/homematic/lock.py index b79f28f2bc7d05..7640146b422c4d 100644 --- a/homeassistant/components/homematic/lock.py +++ b/homeassistant/components/homematic/lock.py @@ -48,7 +48,7 @@ def open(self, **kwargs: Any) -> None: """Open the door latch.""" self._hmdevice.open() - def _init_data_struct(self): + def _init_data_struct(self) -> None: """Generate the data dictionary (self._data) from metadata.""" self._state = "STATE" self._data.update({self._state: None}) diff --git a/homeassistant/components/homematic/sensor.py b/homeassistant/components/homematic/sensor.py index bdd446d70911a9..0ddc319626e0c8 100644 --- a/homeassistant/components/homematic/sensor.py +++ b/homeassistant/components/homematic/sensor.py @@ -339,7 +339,7 @@ def native_value(self): # No cast, return original value return self._hm_get_state() - def _init_data_struct(self): + def _init_data_struct(self) -> None: """Generate a data dictionary (self._data) from metadata.""" if self._state: self._data.update({self._state: None}) diff --git a/homeassistant/components/homematic/switch.py b/homeassistant/components/homematic/switch.py index 5f7c1f93dc81fb..ac8a2e5fe14edc 100644 --- a/homeassistant/components/homematic/switch.py +++ b/homeassistant/components/homematic/switch.py @@ -35,7 +35,7 @@ class HMSwitch(HMDevice, SwitchEntity): """Representation of a HomeMatic switch.""" @property - def is_on(self): + def is_on(self) -> bool: """Return True if switch is on.""" try: return self._hm_get_state() > 0 @@ -43,7 +43,7 @@ def is_on(self): return False @property - def today_energy_kwh(self): + def today_energy_kwh(self) -> float | None: """Return the current power usage in kWh.""" if "ENERGY_COUNTER" in self._data: try: @@ -61,7 +61,7 @@ def turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" self._hmdevice.off(self._channel) - def _init_data_struct(self): + def _init_data_struct(self) -> None: """Generate the data dictionary (self._data) from metadata.""" self._state = "STATE" self._data.update({self._state: None}) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 13cf5443d0709f..6e4b25e3b8e3e8 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -13,7 +13,7 @@ "requirements": [ "xknx==3.14.0", "xknxproject==3.8.2", - "knx-frontend==2025.12.30.151231" + "knx-frontend==2026.1.15.112308" ], "single_config_entry": true } diff --git a/homeassistant/components/nina/config_flow.py b/homeassistant/components/nina/config_flow.py index 42b68a4c76a2fb..2eeec4de19d4f0 100644 --- a/homeassistant/components/nina/config_flow.py +++ b/homeassistant/components/nina/config_flow.py @@ -141,7 +141,7 @@ async def async_step_user( try: self._all_region_codes_sorted = swap_key_value( - await nina.getAllRegionalCodes() + await nina.get_all_regional_codes() ) except ApiError: return self.async_abort(reason="no_fetch") @@ -221,7 +221,7 @@ async def async_step_init( try: self._all_region_codes_sorted = swap_key_value( - await nina.getAllRegionalCodes() + await nina.get_all_regional_codes() ) except ApiError: return self.async_abort(reason="no_fetch") diff --git a/homeassistant/components/nina/coordinator.py b/homeassistant/components/nina/coordinator.py index 7097b24e41f0ee..175b128fdba972 100644 --- a/homeassistant/components/nina/coordinator.py +++ b/homeassistant/components/nina/coordinator.py @@ -66,7 +66,7 @@ def __init__( regions: dict[str, str] = config_entry.data[CONF_REGIONS] for region in regions: - self._nina.addRegion(region) + self._nina.add_region(region) super().__init__( hass, @@ -151,7 +151,7 @@ def _parse_data(self) -> dict[str, list[NinaWarningData]]: raw_warn.sent or "", raw_warn.start or "", raw_warn.expires or "", - raw_warn.isValid(), + raw_warn.is_valid, ) warnings_for_regions.append(warning_data) diff --git a/homeassistant/components/nina/manifest.json b/homeassistant/components/nina/manifest.json index 85ac355c08da5f..80bcb4d24b1823 100644 --- a/homeassistant/components/nina/manifest.json +++ b/homeassistant/components/nina/manifest.json @@ -8,6 +8,6 @@ "iot_class": "cloud_polling", "loggers": ["pynina"], "quality_scale": "bronze", - "requirements": ["pynina==0.3.6"], + "requirements": ["pynina==1.0.2"], "single_config_entry": true } diff --git a/homeassistant/components/oasa_telematics/sensor.py b/homeassistant/components/oasa_telematics/sensor.py index a1ea90a7b16b50..920af78b4ee580 100644 --- a/homeassistant/components/oasa_telematics/sensor.py +++ b/homeassistant/components/oasa_telematics/sensor.py @@ -2,9 +2,10 @@ from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta import logging from operator import itemgetter +from typing import Any import oasatelematics import voluptuous as vol @@ -55,9 +56,9 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the OASA Telematics sensor.""" - name = config[CONF_NAME] - stop_id = config[CONF_STOP_ID] - route_id = config.get(CONF_ROUTE_ID) + name: str = config[CONF_NAME] + stop_id: str = config[CONF_STOP_ID] + route_id: str = config[CONF_ROUTE_ID] data = OASATelematicsData(stop_id, route_id) @@ -68,42 +69,31 @@ class OASATelematicsSensor(SensorEntity): """Implementation of the OASA Telematics sensor.""" _attr_attribution = "Data retrieved from telematics.oasa.gr" + _attr_device_class = SensorDeviceClass.TIMESTAMP _attr_icon = "mdi:bus" - def __init__(self, data, stop_id, route_id, name): + def __init__( + self, data: OASATelematicsData, stop_id: str, route_id: str, name: str + ) -> None: """Initialize the sensor.""" self.data = data - self._name = name + self._attr_name = name self._stop_id = stop_id self._route_id = route_id - self._name_data = self._times = self._state = None + self._name_data: dict[str, Any] | None = None + self._times: list[dict[str, Any]] | None = None @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def device_class(self) -> SensorDeviceClass: - """Return the class of this sensor.""" - return SensorDeviceClass.TIMESTAMP - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" params = {} if self._times is not None: next_arrival_data = self._times[0] if ATTR_NEXT_ARRIVAL in next_arrival_data: - next_arrival = next_arrival_data[ATTR_NEXT_ARRIVAL] + next_arrival: datetime = next_arrival_data[ATTR_NEXT_ARRIVAL] params.update({ATTR_NEXT_ARRIVAL: next_arrival.isoformat()}) if len(self._times) > 1: - second_next_arrival_time = self._times[1][ATTR_NEXT_ARRIVAL] + second_next_arrival_time: datetime = self._times[1][ATTR_NEXT_ARRIVAL] if second_next_arrival_time is not None: second_arrival = second_next_arrival_time params.update( @@ -115,12 +105,13 @@ def extra_state_attributes(self): ATTR_STOP_ID: self._stop_id, } ) - params.update( - { - ATTR_ROUTE_NAME: self._name_data[ATTR_ROUTE_NAME], - ATTR_STOP_NAME: self._name_data[ATTR_STOP_NAME], - } - ) + if self._name_data is not None: + params.update( + { + ATTR_ROUTE_NAME: self._name_data[ATTR_ROUTE_NAME], + ATTR_STOP_NAME: self._name_data[ATTR_STOP_NAME], + } + ) return {k: v for k, v in params.items() if v} def update(self) -> None: @@ -130,7 +121,7 @@ def update(self) -> None: self._name_data = self.data.name_data next_arrival_data = self._times[0] if ATTR_NEXT_ARRIVAL in next_arrival_data: - self._state = next_arrival_data[ATTR_NEXT_ARRIVAL] + self._attr_native_value = next_arrival_data[ATTR_NEXT_ARRIVAL] class OASATelematicsData: diff --git a/homeassistant/components/sma/config_flow.py b/homeassistant/components/sma/config_flow.py index 7d76fbe0ec988f..9e9656eb4b9e76 100644 --- a/homeassistant/components/sma/config_flow.py +++ b/homeassistant/components/sma/config_flow.py @@ -144,6 +144,51 @@ async def async_step_user( errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + errors: dict[str, str] = {} + reconf_entry = self._get_reconfigure_entry() + if user_input is not None: + errors, device_info = await self._handle_user_input( + user_input={ + **reconf_entry.data, + **user_input, + } + ) + + if not errors: + await self.async_set_unique_id( + str(device_info["serial"]), raise_on_progress=False + ) + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + reconf_entry, + data_updates={ + CONF_HOST: user_input[CONF_HOST], + CONF_SSL: user_input[CONF_SSL], + CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL], + CONF_GROUP: user_input[CONF_GROUP], + }, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_SSL): cv.boolean, + vol.Optional(CONF_VERIFY_SSL): cv.boolean, + vol.Optional(CONF_GROUP): vol.In(GROUPS), + } + ), + suggested_values=user_input or dict(reconf_entry.data), + ), + errors=errors, + ) + async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: diff --git a/homeassistant/components/sma/strings.json b/homeassistant/components/sma/strings.json index 07e4047de540d8..2e06550754560a 100644 --- a/homeassistant/components/sma/strings.json +++ b/homeassistant/components/sma/strings.json @@ -3,7 +3,9 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "You selected a different SMA device than the one this config entry was configured with, this is not allowed." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -29,6 +31,17 @@ "description": "The SMA integration needs to re-authenticate your connection details", "title": "[%key:common::config_flow::title::reauth%]" }, + "reconfigure": { + "data": { + "group": "[%key:component::sma::config::step::user::data::group%]", + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "ssl": "[%key:common::config_flow::data::ssl%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "description": "Use the following form to reconfigure your SMA device.", + "title": "Reconfigure SMA Solar Integration" + }, "user": { "data": { "group": "Group", diff --git a/homeassistant/components/smartthings/audio.py b/homeassistant/components/smartthings/audio.py deleted file mode 100644 index d45435a8da2978..00000000000000 --- a/homeassistant/components/smartthings/audio.py +++ /dev/null @@ -1,265 +0,0 @@ -"""Audio helper for SmartThings audio notifications.""" - -from __future__ import annotations - -import asyncio -import contextlib -from dataclasses import dataclass -from datetime import timedelta -import logging -import secrets - -from aiohttp import hdrs, web - -from homeassistant.components import ffmpeg -from homeassistant.components.http import HomeAssistantView -from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.network import NoURLAvailableError, get_url - -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - -PCM_SAMPLE_RATE = 24000 -PCM_SAMPLE_WIDTH = 2 -PCM_CHANNELS = 1 -PCM_MIME = "audio/L16" -PCM_EXTENSION = ".pcm" -WARNING_DURATION_SECONDS = 40 -FFMPEG_MAX_DURATION_SECONDS = 10 * 60 -TRANSCODE_TIMEOUT_SECONDS = WARNING_DURATION_SECONDS + 10 -_TRUNCATION_EPSILON = 1 / PCM_SAMPLE_RATE -ENTRY_TTL = timedelta(minutes=5) -MAX_STORED_ENTRIES = 4 # Limit the number of cached notifications. - -PCM_FRAME_BYTES = PCM_SAMPLE_WIDTH * PCM_CHANNELS - -DATA_AUDIO_MANAGER = "audio_manager" - - -class SmartThingsAudioError(HomeAssistantError): - """Error raised when SmartThings audio preparation fails.""" - - -@dataclass -class _AudioEntry: - """Stored PCM audio entry.""" - - pcm: bytes - created: float - expires: float - - -class SmartThingsAudioManager(HomeAssistantView): - """Manage PCM proxy URLs for SmartThings audio notifications.""" - - url = "/api/smartthings/audio/{token}" - name = "api:smartthings:audio" - requires_auth = False - - def __init__(self, hass: HomeAssistant) -> None: - """Initialize the manager.""" - self.hass = hass - self._entries: dict[str, _AudioEntry] = {} - self._cleanup_handle: asyncio.TimerHandle | None = None - - async def async_prepare_notification(self, source_url: str) -> str: - """Generate an externally accessible PCM URL for SmartThings.""" - pcm, duration, truncated = await self._transcode_to_pcm(source_url) - if not pcm: - raise SmartThingsAudioError("Converted audio is empty") - - if truncated: - _LOGGER.warning( - "SmartThings audio notification truncated to %s seconds (output length %.1fs); longer sources may be cut off", - FFMPEG_MAX_DURATION_SECONDS, - duration, - ) - elif duration > WARNING_DURATION_SECONDS: - _LOGGER.warning( - "SmartThings audio notification is %.1fs; playback over %s seconds may be cut off", - duration, - WARNING_DURATION_SECONDS, - ) - - token = secrets.token_urlsafe( - 16 - ) # Shorter tokens avoid playback issues in some devices. - now = self.hass.loop.time() - entry = _AudioEntry( - pcm=pcm, - created=now, - expires=now + ENTRY_TTL.total_seconds(), - ) - - self._cleanup(now) - while token in self._entries: - token = secrets.token_urlsafe(16) - self._entries[token] = entry - while len(self._entries) > MAX_STORED_ENTRIES: - dropped_token = next(iter(self._entries)) - self._entries.pop(dropped_token, None) - _LOGGER.debug( - "Dropped oldest SmartThings audio token %s to cap cache", - dropped_token, - ) - self._schedule_cleanup() - - path = f"/api/smartthings/audio/{token}{PCM_EXTENSION}" - try: - base_url = get_url( - self.hass, - allow_internal=True, - allow_external=True, - allow_cloud=True, - prefer_external=False, # Prevent NAT loopback failures; may break non-local access for devices outside the LAN. - prefer_cloud=True, - ) - except NoURLAvailableError as err: - self._entries.pop(token, None) - self._schedule_cleanup() - raise SmartThingsAudioError( - "SmartThings audio notifications require an accessible Home Assistant URL" - ) from err - - return f"{base_url}{path}" - - async def get(self, request: web.Request, token: str) -> web.StreamResponse: - """Serve a PCM audio response.""" - token = token.removesuffix(PCM_EXTENSION) - - now = self.hass.loop.time() - self._cleanup(now) - self._schedule_cleanup() - entry = self._entries.get(token) - - if entry is None: - raise web.HTTPNotFound - - _LOGGER.debug("Serving SmartThings audio token=%s to %s", token, request.remote) - - response = web.Response(body=entry.pcm, content_type=PCM_MIME) - response.headers[hdrs.CACHE_CONTROL] = "no-store" - response.headers[hdrs.ACCEPT_RANGES] = "none" - response.headers[hdrs.CONTENT_DISPOSITION] = ( - f'inline; filename="{token}{PCM_EXTENSION}"' - ) - return response - - async def _transcode_to_pcm(self, source_url: str) -> tuple[bytes, float, bool]: - """Use ffmpeg to convert the source media to 24kHz mono PCM.""" - manager = ffmpeg.get_ffmpeg_manager(self.hass) - command = [ - manager.binary, - "-hide_banner", - "-loglevel", - "error", - "-nostdin", - "-i", - source_url, - "-ac", - str(PCM_CHANNELS), - "-ar", - str(PCM_SAMPLE_RATE), - "-c:a", - "pcm_s16le", - "-t", - str(FFMPEG_MAX_DURATION_SECONDS), - "-f", - "s16le", - "pipe:1", - ] - - try: - process = await asyncio.create_subprocess_exec( - *command, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - except FileNotFoundError as err: - raise SmartThingsAudioError( - "FFmpeg is required for SmartThings audio notifications" - ) from err - - try: - stdout, stderr = await asyncio.wait_for( - process.communicate(), timeout=TRANSCODE_TIMEOUT_SECONDS - ) - except TimeoutError: - _LOGGER.warning( - "FFmpeg timed out after %s seconds while converting SmartThings audio from %s", - TRANSCODE_TIMEOUT_SECONDS, - source_url, - ) - with contextlib.suppress(ProcessLookupError): - process.kill() - stdout, stderr = await process.communicate() - - if process.returncode != 0: - message = stderr.decode().strip() or "unknown error" - _LOGGER.error( - "FFmpeg failed to convert SmartThings audio from %s: %s", - source_url, - message, - ) - raise SmartThingsAudioError( - "Unable to convert audio to PCM for SmartThings" - ) - - if not stdout: - return b"", 0.0, False - - frame_count, remainder = divmod(len(stdout), PCM_FRAME_BYTES) - if remainder: - _LOGGER.debug( - "SmartThings audio conversion produced misaligned PCM: dropping %s extra byte(s)", - remainder, - ) - stdout = stdout[: len(stdout) - remainder] - frame_count = len(stdout) // PCM_FRAME_BYTES - - if frame_count == 0: - return b"", 0.0, False - - duration = frame_count / PCM_SAMPLE_RATE - truncated = duration >= (FFMPEG_MAX_DURATION_SECONDS - _TRUNCATION_EPSILON) - return stdout, duration, truncated - - @callback - def _schedule_cleanup(self) -> None: - """Schedule the next cleanup based on entry expiry.""" - if self._cleanup_handle is not None: - self._cleanup_handle.cancel() - self._cleanup_handle = None - if not self._entries: - return - next_expiry = min(entry.expires for entry in self._entries.values()) - delay = max(0.0, next_expiry - self.hass.loop.time()) - self._cleanup_handle = self.hass.loop.call_later(delay, self._cleanup_callback) - - @callback - def _cleanup_callback(self) -> None: - """Run a cleanup pass.""" - self._cleanup_handle = None - now = self.hass.loop.time() - self._cleanup(now) - self._schedule_cleanup() - - def _cleanup(self, now: float) -> None: - """Remove expired entries.""" - expired = [ - token for token, entry in self._entries.items() if entry.expires <= now - ] - for token in expired: - self._entries.pop(token, None) - - -async def async_get_audio_manager(hass: HomeAssistant) -> SmartThingsAudioManager: - """Return the shared SmartThings audio manager.""" - domain_data = hass.data.setdefault(DOMAIN, {}) - if (manager := domain_data.get(DATA_AUDIO_MANAGER)) is None: - manager = SmartThingsAudioManager(hass) - hass.http.register_view(manager) - domain_data[DATA_AUDIO_MANAGER] = manager - return manager diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 4961611583b2c8..2490404e41f9ad 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -3,7 +3,7 @@ "name": "SmartThings", "codeowners": ["@joostlek"], "config_flow": true, - "dependencies": ["application_credentials", "http", "ffmpeg"], + "dependencies": ["application_credentials"], "dhcp": [ { "hostname": "st*", diff --git a/homeassistant/components/smartthings/media_player.py b/homeassistant/components/smartthings/media_player.py index 02fa4c787e525b..335e8255ae4dd8 100644 --- a/homeassistant/components/smartthings/media_player.py +++ b/homeassistant/components/smartthings/media_player.py @@ -6,22 +6,17 @@ from pysmartthings import Attribute, Capability, Category, Command, SmartThings -from homeassistant.components import media_source from homeassistant.components.media_player import ( MediaPlayerDeviceClass, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, - MediaType, RepeatMode, - async_process_play_media_url, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import FullDevice, SmartThingsConfigEntry -from .audio import SmartThingsAudioError, async_get_audio_manager from .const import MAIN from .entity import SmartThingsEntity @@ -89,7 +84,6 @@ def __init__(self, client: SmartThings, device: FullDevice) -> None: Capability.AUDIO_MUTE, Capability.AUDIO_TRACK_DATA, Capability.AUDIO_VOLUME, - Capability.AUDIO_NOTIFICATION, Capability.MEDIA_INPUT_SOURCE, Capability.MEDIA_PLAYBACK, Capability.MEDIA_PLAYBACK_REPEAT, @@ -134,8 +128,6 @@ def _determine_features(self) -> MediaPlayerEntityFeature: flags |= MediaPlayerEntityFeature.SHUFFLE_SET if self.supports_capability(Capability.MEDIA_PLAYBACK_REPEAT): flags |= MediaPlayerEntityFeature.REPEAT_SET - if self.supports_capability(Capability.AUDIO_NOTIFICATION): - flags |= MediaPlayerEntityFeature.PLAY_MEDIA return flags async def async_turn_off(self, **kwargs: Any) -> None: @@ -241,40 +233,6 @@ async def async_set_repeat(self, repeat: RepeatMode) -> None: argument=HA_REPEAT_MODE_TO_SMARTTHINGS[repeat], ) - async def async_play_media( - self, media_type: MediaType | str, media_id: str, **kwargs: Any - ) -> None: - """Play media using SmartThings audio notifications.""" - if not self.supports_capability(Capability.AUDIO_NOTIFICATION): - raise HomeAssistantError("Device does not support audio notifications") - - if media_type not in (MediaType.MUSIC,): - raise HomeAssistantError( - "Unsupported media type for SmartThings audio notification" - ) - - if media_source.is_media_source_id(media_id): - play_item = await media_source.async_resolve_media( - self.hass, media_id, self.entity_id - ) - media_id = async_process_play_media_url(self.hass, play_item.url) - else: - media_id = async_process_play_media_url(self.hass, media_id) - - audio_manager = await async_get_audio_manager(self.hass) - try: - proxy_url = await audio_manager.async_prepare_notification(media_id) - except SmartThingsAudioError as err: - raise HomeAssistantError(str(err)) from err - - command = Command("playTrackAndResume") - - await self.execute_device_command( - Capability.AUDIO_NOTIFICATION, - command, - argument=[proxy_url], - ) - @property def media_title(self) -> str | None: """Title of current playing media.""" diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index 26ee052f6ddd3c..ab21d0a8670c13 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -5,9 +5,11 @@ from collections.abc import Sequence from dataclasses import dataclass from datetime import timedelta +import logging from uiprotect.data import ( Camera, + Chime, Doorlock, Light, ModelType, @@ -30,6 +32,8 @@ ) from .utils import async_ufp_instance_command +_LOGGER = logging.getLogger(__name__) + PARALLEL_UPDATES = 0 @@ -245,6 +249,51 @@ def _get_chime_duration(obj: Camera) -> int: } +def _async_chime_ring_volume_entities( + data: ProtectData, + chime: Chime, +) -> list[ChimeRingVolumeNumber]: + """Generate ring volume entities for each paired camera on a chime.""" + entities: list[ChimeRingVolumeNumber] = [] + + if not chime.is_adopted_by_us: + return entities + + auth_user = data.api.bootstrap.auth_user + if not chime.can_write(auth_user): + return entities + + for ring_setting in chime.ring_settings: + camera = data.api.bootstrap.cameras.get(ring_setting.camera_id) + if camera is None: + _LOGGER.debug( + "Camera %s not found for chime %s ring volume", + ring_setting.camera_id, + chime.display_name, + ) + continue + entities.append(ChimeRingVolumeNumber(data, chime, camera)) + + return entities + + +def _async_all_chime_ring_volume_entities( + data: ProtectData, + chime: Chime | None = None, +) -> list[ChimeRingVolumeNumber]: + """Generate all ring volume entities for chimes.""" + entities: list[ChimeRingVolumeNumber] = [] + + if chime is not None: + return _async_chime_ring_volume_entities(data, chime) + + for device in data.get_by_types({ModelType.CHIME}): + if isinstance(device, Chime): + entities.extend(_async_chime_ring_volume_entities(data, device)) + + return entities + + async def async_setup_entry( hass: HomeAssistant, entry: UFPConfigEntry, @@ -255,23 +304,26 @@ async def async_setup_entry( @callback def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: - async_add_entities( - async_all_device_entities( - data, - ProtectNumbers, - model_descriptions=_MODEL_DESCRIPTIONS, - ufp_device=device, - ) - ) - - data.async_subscribe_adopt(_add_new_device) - async_add_entities( - async_all_device_entities( + entities = async_all_device_entities( data, ProtectNumbers, model_descriptions=_MODEL_DESCRIPTIONS, + ufp_device=device, ) + # Add ring volume entities for chimes + if isinstance(device, Chime): + entities += _async_all_chime_ring_volume_entities(data, device) + async_add_entities(entities) + + data.async_subscribe_adopt(_add_new_device) + entities = async_all_device_entities( + data, + ProtectNumbers, + model_descriptions=_MODEL_DESCRIPTIONS, ) + # Add ring volume entities for all chimes + entities += _async_all_chime_ring_volume_entities(data) + async_add_entities(entities) class ProtectNumbers(ProtectDeviceEntity, NumberEntity): @@ -302,3 +354,62 @@ def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: async def async_set_native_value(self, value: float) -> None: """Set new value.""" await self.entity_description.ufp_set(self.device, value) + + +class ChimeRingVolumeNumber(ProtectDeviceEntity, NumberEntity): + """A UniFi Protect Number Entity for ring volume per camera on a chime.""" + + device: Chime + _state_attrs = ("_attr_available", "_attr_native_value") + _attr_native_max_value: float = 100 + _attr_native_min_value: float = 0 + _attr_native_step: float = 1 + _attr_native_unit_of_measurement = PERCENTAGE + _attr_entity_category = EntityCategory.CONFIG + + def __init__( + self, + data: ProtectData, + chime: Chime, + camera: Camera, + ) -> None: + """Initialize the ring volume number entity.""" + self._camera_id = camera.id + # Use chime MAC and camera ID for unique ID + super().__init__(data, chime) + self._attr_unique_id = f"{chime.mac}_ring_volume_{camera.id}" + self._attr_translation_key = "chime_ring_volume" + self._attr_translation_placeholders = {"camera_name": camera.display_name} + # BaseProtectEntity sets _attr_name = None when no description is passed, + # which prevents translation_key from being used. Delete to enable translations. + del self._attr_name + + @callback + def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: + """Update entity from protect device.""" + super()._async_update_device_from_protect(device) + self._attr_native_value = self._get_ring_volume() + + def _get_ring_volume(self) -> int | None: + """Get the ring volume for this camera from the chime's ring settings.""" + for ring_setting in self.device.ring_settings: + if ring_setting.camera_id == self._camera_id: + return ring_setting.volume + return None + + @property + def available(self) -> bool: + """Return if entity is available.""" + # Entity is unavailable if the camera is no longer paired with the chime + return super().available and self._get_ring_volume() is not None + + @async_ufp_instance_command + async def async_set_native_value(self, value: float) -> None: + """Set new ring volume value.""" + camera = self.data.api.bootstrap.cameras.get(self._camera_id) + if camera is None: + _LOGGER.warning( + "Cannot set ring volume: camera %s not found", self._camera_id + ) + return + await self.device.set_volume_for_camera_public(camera, int(value)) diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index 0ebe3b5dd14629..0d9812abcd3943 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -323,6 +323,9 @@ "chime_duration": { "name": "Chime duration" }, + "chime_ring_volume": { + "name": "Ring volume ({camera_name})" + }, "doorbell_ring_volume": { "name": "Doorbell ring volume" }, diff --git a/requirements_all.txt b/requirements_all.txt index e3858be0ed0c25..85b2adb1e506a5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1351,7 +1351,7 @@ kiwiki-client==0.1.1 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.12.30.151231 +knx-frontend==2026.1.15.112308 # homeassistant.components.konnected konnected==1.2.0 @@ -2234,7 +2234,7 @@ pynetgear==0.10.10 pynetio==0.1.9.1 # homeassistant.components.nina -pynina==0.3.6 +pynina==1.0.2 # homeassistant.components.nintendo_parental_controls pynintendoauth==1.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b58f997f0d57bc..12e48e799d33d9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1185,7 +1185,7 @@ kegtron-ble==1.0.2 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.12.30.151231 +knx-frontend==2026.1.15.112308 # homeassistant.components.konnected konnected==1.2.0 @@ -1887,7 +1887,7 @@ pynecil==4.2.1 pynetgear==0.10.10 # homeassistant.components.nina -pynina==0.3.6 +pynina==1.0.2 # homeassistant.components.nintendo_parental_controls pynintendoauth==1.0.2 diff --git a/tests/components/assist_satellite/test_condition.py b/tests/components/assist_satellite/test_condition.py new file mode 100644 index 00000000000000..afd1c958329348 --- /dev/null +++ b/tests/components/assist_satellite/test_condition.py @@ -0,0 +1,190 @@ +"""Test assist satellite conditions.""" + +from typing import Any + +import pytest + +from homeassistant.components.assist_satellite.entity import AssistSatelliteState +from homeassistant.core import HomeAssistant + +from tests.components import ( + ConditionStateDescription, + assert_condition_gated_by_labs_flag, + create_target_condition, + other_states, + parametrize_condition_states, + parametrize_target_entities, + set_or_remove_state, + target_entities, +) + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" + + +@pytest.fixture +async def target_assist_satellites(hass: HomeAssistant) -> list[str]: + """Create multiple assist satellite entities associated with different targets.""" + return (await target_entities(hass, "assist_satellite"))["included"] + + +@pytest.mark.parametrize( + "condition", + [ + "assist_satellite.is_idle", + "assist_satellite.is_listening", + "assist_satellite.is_processing", + "assist_satellite.is_responding", + ], +) +async def test_assist_satellite_conditions_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, condition: str +) -> None: + """Test the assist satellite conditions are gated by the labs flag.""" + await assert_condition_gated_by_labs_flag(hass, caplog, condition) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("assist_satellite"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states( + condition="assist_satellite.is_idle", + target_states=[AssistSatelliteState.IDLE], + other_states=other_states(AssistSatelliteState.IDLE), + ), + *parametrize_condition_states( + condition="assist_satellite.is_listening", + target_states=[AssistSatelliteState.LISTENING], + other_states=other_states(AssistSatelliteState.LISTENING), + ), + *parametrize_condition_states( + condition="assist_satellite.is_processing", + target_states=[AssistSatelliteState.PROCESSING], + other_states=other_states(AssistSatelliteState.PROCESSING), + ), + *parametrize_condition_states( + condition="assist_satellite.is_responding", + target_states=[AssistSatelliteState.RESPONDING], + other_states=other_states(AssistSatelliteState.RESPONDING), + ), + ], +) +async def test_assist_satellite_state_condition_behavior_any( + hass: HomeAssistant, + target_assist_satellites: list[str], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test the assist satellite state condition with the 'any' behavior.""" + other_entity_ids = set(target_assist_satellites) - {entity_id} + + # Set all assist satellites, including the tested one, to the initial state + for eid in target_assist_satellites: + set_or_remove_state(hass, eid, states[0]["included"]) + await hass.async_block_till_done() + + condition = await create_target_condition( + hass, + condition=condition, + target=condition_target_config, + behavior="any", + ) + + for state in states: + included_state = state["included"] + set_or_remove_state(hass, entity_id, included_state) + await hass.async_block_till_done() + assert condition(hass) == state["condition_true"] + + # Check if changing other assist satellites also passes the condition + for other_entity_id in other_entity_ids: + set_or_remove_state(hass, other_entity_id, included_state) + await hass.async_block_till_done() + assert condition(hass) == state["condition_true"] + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("assist_satellite"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states( + condition="assist_satellite.is_idle", + target_states=[AssistSatelliteState.IDLE], + other_states=other_states(AssistSatelliteState.IDLE), + ), + *parametrize_condition_states( + condition="assist_satellite.is_listening", + target_states=[AssistSatelliteState.LISTENING], + other_states=other_states(AssistSatelliteState.LISTENING), + ), + *parametrize_condition_states( + condition="assist_satellite.is_processing", + target_states=[AssistSatelliteState.PROCESSING], + other_states=other_states(AssistSatelliteState.PROCESSING), + ), + *parametrize_condition_states( + condition="assist_satellite.is_responding", + target_states=[AssistSatelliteState.RESPONDING], + other_states=other_states(AssistSatelliteState.RESPONDING), + ), + ], +) +async def test_assist_satellite_state_condition_behavior_all( + hass: HomeAssistant, + target_assist_satellites: list[str], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test the assist satellite state condition with the 'all' behavior.""" + other_entity_ids = set(target_assist_satellites) - {entity_id} + + # Set all assist satellites, including the tested one, to the initial state + for eid in target_assist_satellites: + set_or_remove_state(hass, eid, states[0]["included"]) + await hass.async_block_till_done() + + condition = await create_target_condition( + hass, + condition=condition, + target=condition_target_config, + behavior="all", + ) + + for state in states: + included_state = state["included"] + + set_or_remove_state(hass, entity_id, included_state) + await hass.async_block_till_done() + # The condition passes if all entities are either in a target state or invalid + assert condition(hass) == ( + (not state["state_valid"]) + or (state["condition_true"] and entities_in_target == 1) + ) + + for other_entity_id in other_entity_ids: + set_or_remove_state(hass, other_entity_id, included_state) + await hass.async_block_till_done() + + # The condition passes if all entities are either in a target state or invalid + assert condition(hass) == ( + (not state["state_valid"]) or state["condition_true"] + ) diff --git a/tests/components/esphome/test_analytics.py b/tests/components/esphome/test_analytics.py index f4de75b2ee0c63..1ede9d76ea0867 100644 --- a/tests/components/esphome/test_analytics.py +++ b/tests/components/esphome/test_analytics.py @@ -1,7 +1,5 @@ """Tests for analytics platform.""" -import pytest - from homeassistant.components.analytics import async_devices_payload from homeassistant.components.esphome import DOMAIN from homeassistant.core import HomeAssistant @@ -11,7 +9,6 @@ from tests.common import MockConfigEntry -@pytest.mark.asyncio async def test_analytics( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: diff --git a/tests/components/fluss/__init__.py b/tests/components/fluss/__init__.py index 1849ed37655eeb..6df50f8bd60bba 100644 --- a/tests/components/fluss/__init__.py +++ b/tests/components/fluss/__init__.py @@ -52,7 +52,6 @@ async def test_async_setup_entry_errors( assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR -@pytest.mark.asyncio async def test_async_setup_entry_success( hass: HomeAssistant, mock_config_entry: MagicMock, @@ -67,7 +66,6 @@ async def test_async_setup_entry_success( ) -@pytest.mark.asyncio async def test_async_unload_entry( hass: HomeAssistant, mock_config_entry: MagicMock, @@ -87,7 +85,6 @@ async def test_async_unload_entry( assert mock_config_entry.state is ConfigEntryState.NOT_LOADED -@pytest.mark.asyncio async def test_platforms_forwarded( hass: HomeAssistant, mock_config_entry: MagicMock, diff --git a/tests/components/imeon_inverter/test_sensor.py b/tests/components/imeon_inverter/test_sensor.py index 9e69badea640bb..eca3bb3e2fa37c 100644 --- a/tests/components/imeon_inverter/test_sensor.py +++ b/tests/components/imeon_inverter/test_sensor.py @@ -38,7 +38,6 @@ async def test_sensors( ValueError, ], ) -@pytest.mark.asyncio async def test_sensor_unavailable_on_update_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/matter/conftest.py b/tests/components/matter/conftest.py index 68c62d700a2d84..5f4c9d8eb98207 100644 --- a/tests/components/matter/conftest.py +++ b/tests/components/matter/conftest.py @@ -129,6 +129,7 @@ async def integration_fixture( "oven", "pressure_sensor", "pump", + "resideo_x2s_thermostat", "room_airconditioner", "secuyou_smart_lock", "silabs_dishwasher", diff --git a/tests/components/matter/fixtures/nodes/resideo_x2s_thermostat.json b/tests/components/matter/fixtures/nodes/resideo_x2s_thermostat.json new file mode 100644 index 00000000000000..32a9f3839bb25c --- /dev/null +++ b/tests/components/matter/fixtures/nodes/resideo_x2s_thermostat.json @@ -0,0 +1,190 @@ +{ + "node_id": 4, + "date_commissioned": "2026-01-04T01:49:35.244151", + "last_interview": "2026-01-04T03:11:54.520702", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 48, 51, 60, 62, 63], + "0/29/2": [], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 2, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 + } + ], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 17, + "0/40/1": "Resideo", + "0/40/2": 4890, + "0/40/3": "X2S Smart Thermostat", + "0/40/4": 4096, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 2, + "0/40/8": "X2S_STAT_NPS_002", + "0/40/9": 1, + "0/40/10": "2.0.0.0", + "0/40/15": "**REDACTED**", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/21": 16973824, + "0/40/22": 1, + "0/40/65532": 0, + "0/40/65533": 3, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 19, 21, 22, 65528, 65529, 65531, + 65532, 65533 + ], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 2, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "0": "r0", + "1": true, + "2": null, + "3": null, + "4": "XPzhnNpQ", + "5": ["wKgJoQ=="], + "6": ["/oAAAAAAAABe/OH//pzaUA=="], + "7": 1 + } + ], + "0/51/1": 2, + "0/51/2": 5105, + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 2, + "0/51/65528": [2], + "0/51/65529": [0, 1], + "0/51/65531": [0, 1, 2, 8, 65528, 65529, 65531, 65532, 65533], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRBBgkBwEkCAEwCUEEKl96Nj13XjcDn1kF4aoFwMWb9leBXP2Urts/tvLi1DF1UZkPEBrfZ5YYqd5tps3ELof6pBX91oACxfbnYF7UyzcKNQEoARgkAgE2AwQCBAEYMAQUY8nv41nGGNtJapsJ0+8/6EAnt9owBRRnrnI3xp/0zhgwIJN0RMbKS99orRgwC0BsruLcJINuIyVVZHD5AlYCuha4XhnLxtIjyYCXIIHGNuu39D6u/j94efSHPrOvVjAHXY56+z5KJguTzlTBOC5tGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEOoay7Kv2Hog5xV7knNJLl+Ywx5Sr/jrp6/PV5XF57NXm4UJfgdb6Ja7rZ+965UjigpYh+JVAVvCRK1xNgkikiDcKNQEpARgkAmAwBBRnrnI3xp/0zhgwIJN0RMbKS99orTAFFEHaAQy9nUPjHiRv7FIwcIp50v+EGDALQF5JHY0EJKgFC63BM4uO0mrkHpeTCSDpUEEz7IsvkdxAgUToWftgJSC3B7gqDelohC4uqReJpmeQ64F5XqYtB3AY", + "254": 1 + } + ], + "0/62/1": [ + { + "1": "BGkTBQSFwwkc5WoOUncXmIahsjWs9bKfHyZRWpArIFMjhyjNKqURWvFS8xbVXTFf+UlFmJF2JnlMX4WgKjXkOLo=", + "2": 4939, + "3": 2, + "4": 4, + "5": "Home", + "254": 1 + } + ], + "0/62/2": 5, + "0/62/3": 1, + "0/62/4": [ + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEaRMFBIXDCRzlag5SdxeYhqGyNaz1sp8fJlFakCsgUyOHKM0qpRFa8VLzFtVdMV/5SUWYkXYmeUxfhaAqNeQ4ujcKNQEpARgkAmAwBBRB2gEMvZ1D4x4kb+xSMHCKedL/hDAFFEHaAQy9nUPjHiRv7FIwcIp50v+EGDALQE/fBBea6WzXom6INogGzGdop0w7g8j4dcIo6v8Id2k+sttWqeL5we7dDJonx/m2MgVsQTKCeVhtN/nzT4stvmEY" + ], + "0/62/5": 1, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 2, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 4, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/4/0": 128, + "1/4/65532": 1, + "1/4/65533": 4, + "1/4/65528": [0, 1, 2, 3], + "1/4/65529": [0, 1, 2, 3, 4, 5], + "1/4/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/29/0": [ + { + "0": 769, + "1": 1 + } + ], + "1/29/1": [3, 4, 29, 513], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 2, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/513/0": 2055, + "1/513/3": 443, + "1/513/4": 3221, + "1/513/5": 1000, + "1/513/6": 3721, + "1/513/17": 2666, + "1/513/18": 2166, + "1/513/25": 0, + "1/513/27": 4, + "1/513/28": 0, + "1/513/65532": 3, + "1/513/65533": 6, + "1/513/65528": [], + "1/513/65529": [0], + "1/513/65531": [ + 0, 3, 4, 5, 6, 17, 18, 25, 27, 28, 65528, 65529, 65531, 65532, 65533 + ] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/snapshots/test_button.ambr b/tests/components/matter/snapshots/test_button.ambr index 9655b13d4c1706..1c5d636bafd431 100644 --- a/tests/components/matter/snapshots/test_button.ambr +++ b/tests/components/matter/snapshots/test_button.ambr @@ -3087,6 +3087,56 @@ 'state': 'unknown', }) # --- +# name: test_buttons[resideo_x2s_thermostat][button.x2s_smart_thermostat_identify-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.x2s_smart_thermostat_identify', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Identify', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Identify', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-IdentifyButton-3-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[resideo_x2s_thermostat][button.x2s_smart_thermostat_identify-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'identify', + 'friendly_name': 'X2S Smart Thermostat Identify', + }), + 'context': , + 'entity_id': 'button.x2s_smart_thermostat_identify', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_buttons[secuyou_smart_lock][button.secuyou_smart_lock_identify-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_climate.ambr b/tests/components/matter/snapshots/test_climate.ambr index da708ca77a0c82..84f75fd427e471 100644 --- a/tests/components/matter/snapshots/test_climate.ambr +++ b/tests/components/matter/snapshots/test_climate.ambr @@ -467,6 +467,75 @@ 'state': 'heat_cool', }) # --- +# name: test_climates[resideo_x2s_thermostat][climate.x2s_smart_thermostat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 32.2, + 'min_temp': 4.4, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.x2s_smart_thermostat', + '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': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-MatterThermostat-513-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_climates[resideo_x2s_thermostat][climate.x2s_smart_thermostat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.6, + 'friendly_name': 'X2S Smart Thermostat', + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 32.2, + 'min_temp': 4.4, + 'supported_features': , + 'temperature': 21.7, + }), + 'context': , + 'entity_id': 'climate.x2s_smart_thermostat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_climates[room_airconditioner][climate.room_airconditioner-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 7ea5b0788a5de2..81778716447de4 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -9748,6 +9748,63 @@ 'state': '60.0', }) # --- +# name: test_sensors[resideo_x2s_thermostat][sensor.x2s_smart_thermostat_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.x2s_smart_thermostat_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-ThermostatLocalTemperature-513-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[resideo_x2s_thermostat][sensor.x2s_smart_thermostat_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'X2S Smart Thermostat Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.x2s_smart_thermostat_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.55', + }) +# --- # name: test_sensors[room_airconditioner][sensor.room_airconditioner_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/nina/__init__.py b/tests/components/nina/__init__.py index 539811e7f400df..0cf078d5b79b93 100644 --- a/tests/components/nina/__init__.py +++ b/tests/components/nina/__init__.py @@ -17,7 +17,7 @@ async def setup_platform(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: """Set up the NINA platform.""" with patch( - "pynina.baseApi.BaseAPI._makeRequest", + "pynina.api_client.APIClient.make_request", wraps=mocked_request_function, ): await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/nina/snapshots/test_diagnostics.ambr b/tests/components/nina/snapshots/test_diagnostics.ambr index 331323ff76c95d..d9ff3c5f3386c6 100644 --- a/tests/components/nina/snapshots/test_diagnostics.ambr +++ b/tests/components/nina/snapshots/test_diagnostics.ambr @@ -25,7 +25,7 @@ 'id': 'biw.BIWAPP-69634', 'is_valid': False, 'recommended_actions': '', - 'sender': '', + 'sender': None, 'sent': '1999-08-07T10:59:00+02:00', 'severity': 'Minor', 'start': '', diff --git a/tests/components/nina/test_config_flow.py b/tests/components/nina/test_config_flow.py index 4bde1891a80491..25048a9654eb84 100644 --- a/tests/components/nina/test_config_flow.py +++ b/tests/components/nina/test_config_flow.py @@ -49,7 +49,7 @@ def assert_dummy_entry_created(result: dict[str, Any]) -> None: async def test_step_user_connection_error(hass: HomeAssistant) -> None: """Test starting a flow by user but no connection.""" with patch( - "pynina.baseApi.BaseAPI._makeRequest", + "pynina.api_client.APIClient.make_request", side_effect=ApiError("Could not connect to Api"), ): result: dict[str, Any] = await hass.config_entries.flow.async_init( @@ -63,7 +63,7 @@ async def test_step_user_connection_error(hass: HomeAssistant) -> None: async def test_step_user_unexpected_exception(hass: HomeAssistant) -> None: """Test starting a flow by user but with an unexpected exception.""" with patch( - "pynina.baseApi.BaseAPI._makeRequest", + "pynina.api_client.APIClient.make_request", side_effect=Exception("DUMMY"), ): result: dict[str, Any] = await hass.config_entries.flow.async_init( @@ -77,7 +77,7 @@ async def test_step_user_unexpected_exception(hass: HomeAssistant) -> None: async def test_step_user(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test starting a flow by user with valid values.""" with patch( - "pynina.baseApi.BaseAPI._makeRequest", + "pynina.api_client.APIClient.make_request", wraps=mocked_request_function, ): result: dict[str, Any] = await hass.config_entries.flow.async_init( @@ -95,7 +95,7 @@ async def test_step_user(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No async def test_step_user_no_selection(hass: HomeAssistant) -> None: """Test starting a flow by user with no selection.""" with patch( - "pynina.baseApi.BaseAPI._makeRequest", + "pynina.api_client.APIClient.make_request", wraps=mocked_request_function, ): result: dict[str, Any] = await hass.config_entries.flow.async_init( @@ -121,7 +121,7 @@ async def test_step_user_already_configured( ) -> None: """Test starting a flow by user, but it was already configured.""" with patch( - "pynina.baseApi.BaseAPI._makeRequest", + "pynina.api_client.APIClient.make_request", wraps=mocked_request_function, ): result = await hass.config_entries.flow.async_init( @@ -141,7 +141,7 @@ async def test_options_flow_init( with ( patch( - "pynina.baseApi.BaseAPI._makeRequest", + "pynina.api_client.APIClient.make_request", wraps=mocked_request_function, ), ): @@ -195,7 +195,7 @@ async def test_options_flow_with_no_selection( with ( patch( - "pynina.baseApi.BaseAPI._makeRequest", + "pynina.api_client.APIClient.make_request", wraps=mocked_request_function, ), ): @@ -263,7 +263,7 @@ async def test_options_flow_connection_error( await setup_platform(hass, mock_config_entry) with patch( - "pynina.baseApi.BaseAPI._makeRequest", + "pynina.api_client.APIClient.make_request", side_effect=ApiError("Could not connect to Api"), ): result = await hass.config_entries.options.async_init( @@ -283,7 +283,7 @@ async def test_options_flow_unexpected_exception( with ( patch( - "pynina.baseApi.BaseAPI._makeRequest", + "pynina.api_client.APIClient.make_request", side_effect=Exception("DUMMY"), ), ): @@ -312,7 +312,7 @@ async def test_options_flow_entity_removal( with ( patch( - "pynina.baseApi.BaseAPI._makeRequest", + "pynina.api_client.APIClient.make_request", wraps=mocked_request_function, ), ): diff --git a/tests/components/nina/test_diagnostics.py b/tests/components/nina/test_diagnostics.py index 9a5538bacbbf9e..fdce574b6f49a7 100644 --- a/tests/components/nina/test_diagnostics.py +++ b/tests/components/nina/test_diagnostics.py @@ -32,7 +32,7 @@ async def test_diagnostics( """Test diagnostics.""" with patch( - "pynina.baseApi.BaseAPI._makeRequest", + "pynina.api_client.APIClient.make_request", wraps=mocked_request_function, ): config_entry: MockConfigEntry = MockConfigEntry( diff --git a/tests/components/nina/test_init.py b/tests/components/nina/test_init.py index feba6110f3fb3e..ac6fa3b6617c48 100644 --- a/tests/components/nina/test_init.py +++ b/tests/components/nina/test_init.py @@ -28,7 +28,7 @@ async def init_integration(hass: HomeAssistant) -> MockConfigEntry: """Set up the NINA integration in Home Assistant.""" with patch( - "pynina.baseApi.BaseAPI._makeRequest", + "pynina.api_client.APIClient.make_request", wraps=mocked_request_function, ): entry: MockConfigEntry = MockConfigEntry( @@ -54,7 +54,7 @@ async def test_config_migration_from1_1(hass: HomeAssistant) -> None: ) with patch( - "pynina.baseApi.BaseAPI._makeRequest", + "pynina.api_client.APIClient.make_request", wraps=mocked_request_function, ): old_conf_entry.add_to_hass(hass) @@ -82,7 +82,7 @@ async def test_config_migration_from1_2(hass: HomeAssistant) -> None: ) with patch( - "pynina.baseApi.BaseAPI._makeRequest", + "pynina.api_client.APIClient.make_request", wraps=mocked_request_function, ): old_conf_entry.add_to_hass(hass) @@ -104,7 +104,7 @@ async def test_config_migration_downgrade(hass: HomeAssistant) -> None: ) with patch( - "pynina.baseApi.BaseAPI._makeRequest", + "pynina.api_client.APIClient.make_request", wraps=mocked_request_function, ): conf_entry.add_to_hass(hass) @@ -126,7 +126,7 @@ async def test_config_entry_not_ready(hass: HomeAssistant) -> None: async def test_sensors_connection_error(hass: HomeAssistant) -> None: """Test the creation and values of the NINA sensors with no connected.""" with patch( - "pynina.baseApi.BaseAPI._makeRequest", + "pynina.api_client.APIClient.make_request", side_effect=ApiError("Could not connect to Api"), ): conf_entry: MockConfigEntry = MockConfigEntry( diff --git a/tests/components/onvif/test_init.py b/tests/components/onvif/test_init.py index c176bdcc11295b..3a4a5dcb33d5be 100644 --- a/tests/components/onvif/test_init.py +++ b/tests/components/onvif/test_init.py @@ -2,8 +2,6 @@ from unittest.mock import MagicMock, patch -import pytest - from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -12,7 +10,6 @@ from tests.common import MockConfigEntry -@pytest.mark.asyncio async def test_migrate_camera_entities_unique_ids(hass: HomeAssistant) -> None: """Test that camera entities unique ids get migrated properly.""" config_entry = MockConfigEntry(domain="onvif", unique_id=MAC) diff --git a/tests/components/ridwell/test_calendar.py b/tests/components/ridwell/test_calendar.py index 811455a7ee3251..e0fd36aa077421 100644 --- a/tests/components/ridwell/test_calendar.py +++ b/tests/components/ridwell/test_calendar.py @@ -21,7 +21,6 @@ END_DATE = date(2025, 10, 5) -@pytest.mark.asyncio @pytest.mark.parametrize( ( "pickup_name", diff --git a/tests/components/sma/__init__.py b/tests/components/sma/__init__.py index eebaf43ccd81b0..99ae823dd973a1 100644 --- a/tests/components/sma/__init__.py +++ b/tests/components/sma/__init__.py @@ -35,6 +35,14 @@ CONF_PASSWORD: "new_password", } +MOCK_USER_RECONFIGURE = { + CONF_HOST: "1.1.1.2", + CONF_SSL: True, + CONF_VERIFY_SSL: False, + CONF_GROUP: "user", +} + + MOCK_DHCP_DISCOVERY_INPUT = { CONF_SSL: True, CONF_VERIFY_SSL: False, diff --git a/tests/components/sma/test_config_flow.py b/tests/components/sma/test_config_flow.py index 63130c5cf359ab..f927f9979da23f 100644 --- a/tests/components/sma/test_config_flow.py +++ b/tests/components/sma/test_config_flow.py @@ -3,11 +3,12 @@ from unittest.mock import AsyncMock, MagicMock, patch from pysma import SmaAuthenticationException, SmaConnectionException, SmaReadException +from pysma.helpers import DeviceInfo import pytest -from homeassistant.components.sma.const import DOMAIN +from homeassistant.components.sma.const import CONF_GROUP, DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER -from homeassistant.const import CONF_MAC +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_SSL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.device_registry import format_mac @@ -19,6 +20,7 @@ MOCK_DHCP_DISCOVERY_INPUT, MOCK_USER_INPUT, MOCK_USER_REAUTH, + MOCK_USER_RECONFIGURE, ) from tests.conftest import MockConfigEntry @@ -311,3 +313,109 @@ async def test_reauth_flow_exceptions( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" + + +async def test_full_flow_reconfigure( + hass: HomeAssistant, + mock_setup_entry: MockConfigEntry, + mock_sma_client: AsyncMock, +) -> None: + """Test the full flow of the config flow.""" + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT, unique_id="123456789") + entry.add_to_hass(hass) + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_USER_RECONFIGURE, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data[CONF_HOST] == "1.1.1.2" + assert entry.data[CONF_SSL] is True + assert entry.data[CONF_VERIFY_SSL] is False + assert entry.data[CONF_GROUP] == "user" + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (SmaConnectionException, "cannot_connect"), + (SmaAuthenticationException, "invalid_auth"), + (SmaReadException, "cannot_retrieve_device_info"), + (Exception, "unknown"), + ], +) +async def test_full_flow_reconfigure_exceptions( + hass: HomeAssistant, + mock_setup_entry: MockConfigEntry, + mock_sma_client: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test we handle cannot connect error and recover from it.""" + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT, unique_id="123456789") + entry.add_to_hass(hass) + result = await entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_sma_client.new_session.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USER_RECONFIGURE, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_sma_client.new_session.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_USER_RECONFIGURE, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data[CONF_HOST] == "1.1.1.2" + assert entry.data[CONF_SSL] is True + assert entry.data[CONF_VERIFY_SSL] is False + assert entry.data[CONF_GROUP] == "user" + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reconfigure_mismatch_id( + hass: HomeAssistant, + mock_setup_entry: MockConfigEntry, + mock_sma_client: AsyncMock, +) -> None: + """Test when a mismatch happens during reconfigure.""" + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT, unique_id="123456789") + entry.add_to_hass(hass) + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + # New device, on purpose to demonstrate we can't switch + different_device = DeviceInfo( + manufacturer="SMA", + name="Different SMA Device", + type="Sunny Boy 5.0", + serial=987654321, + sw_version="2.0.0", + ) + mock_sma_client.device_info = AsyncMock(return_value=different_device) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_USER_RECONFIGURE, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" diff --git a/tests/components/smartthings/__init__.py b/tests/components/smartthings/__init__.py index 35b2269c9b2f05..3395f7f4673eaf 100644 --- a/tests/components/smartthings/__init__.py +++ b/tests/components/smartthings/__init__.py @@ -1,7 +1,5 @@ """Tests for the SmartThings integration.""" -import sys -import types from typing import Any from unittest.mock import AsyncMock @@ -92,38 +90,3 @@ async def trigger_health_update( if call[0][0] == device_id: call[0][1](event) await hass.async_block_till_done() - - -def ensure_haffmpeg_stubs() -> None: - """Ensure haffmpeg stubs are available for SmartThings tests.""" - if "haffmpeg" in sys.modules: - return - - haffmpeg_module = types.ModuleType("haffmpeg") - haffmpeg_core_module = types.ModuleType("haffmpeg.core") - haffmpeg_tools_module = types.ModuleType("haffmpeg.tools") - - class _StubHAFFmpeg: ... - - class _StubFFVersion: - def __init__(self, bin_path: str | None = None) -> None: - self.bin_path = bin_path - - async def get_version(self) -> str: - return "4.0.0" - - class _StubImageFrame: ... - - haffmpeg_core_module.HAFFmpeg = _StubHAFFmpeg - haffmpeg_tools_module.IMAGE_JPEG = b"" - haffmpeg_tools_module.FFVersion = _StubFFVersion - haffmpeg_tools_module.ImageFrame = _StubImageFrame - haffmpeg_module.core = haffmpeg_core_module - haffmpeg_module.tools = haffmpeg_tools_module - - sys.modules["haffmpeg"] = haffmpeg_module - sys.modules["haffmpeg.core"] = haffmpeg_core_module - sys.modules["haffmpeg.tools"] = haffmpeg_tools_module - - -ensure_haffmpeg_stubs() diff --git a/tests/components/smartthings/snapshots/test_media_player.ambr b/tests/components/smartthings/snapshots/test_media_player.ambr index c098edb01b7d30..9e11b4e283c8e6 100644 --- a/tests/components/smartthings/snapshots/test_media_player.ambr +++ b/tests/components/smartthings/snapshots/test_media_player.ambr @@ -37,7 +37,7 @@ 'platform': 'smartthings', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': 'afcf3b91-0000-1111-2222-ddff2a0a6577_main', 'unit_of_measurement': None, @@ -59,7 +59,7 @@ 'HDMI2', 'digital', ]), - 'supported_features': , + 'supported_features': , 'volume_level': 0.01, }), 'context': , @@ -101,7 +101,7 @@ 'platform': 'smartthings', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c_main', 'unit_of_measurement': None, @@ -115,7 +115,7 @@ 'is_volume_muted': False, 'repeat': , 'shuffle': False, - 'supported_features': , + 'supported_features': , 'volume_level': 0.52, }), 'context': , @@ -157,7 +157,7 @@ 'platform': 'smartthings', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': 'c85fced9-c474-4a47-93c2-037cc7829536_main', 'unit_of_measurement': None, @@ -171,7 +171,7 @@ 'is_volume_muted': False, 'media_artist': 'David Guetta', 'media_title': 'Forever Young', - 'supported_features': , + 'supported_features': , 'volume_level': 0.15, }), 'context': , @@ -213,7 +213,7 @@ 'platform': 'smartthings', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac_main', 'unit_of_measurement': None, @@ -228,7 +228,7 @@ 'media_artist': '', 'media_title': '', 'source': 'HDMI1', - 'supported_features': , + 'supported_features': , 'volume_level': 0.17, }), 'context': , @@ -270,7 +270,7 @@ 'platform': 'smartthings', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': 'a75cb1e1-03fd-3c77-ca9f-d4e56c4096c6_main', 'unit_of_measurement': None, @@ -281,7 +281,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'speaker', 'friendly_name': 'Soundbar', - 'supported_features': , + 'supported_features': , }), 'context': , 'entity_id': 'media_player.soundbar', diff --git a/tests/components/smartthings/test_audio.py b/tests/components/smartthings/test_audio.py deleted file mode 100644 index a4310c9080410c..00000000000000 --- a/tests/components/smartthings/test_audio.py +++ /dev/null @@ -1,531 +0,0 @@ -"""Tests for SmartThings audio helper.""" - -from __future__ import annotations - -import asyncio -import logging -from types import SimpleNamespace -from unittest.mock import AsyncMock, patch -from urllib.parse import urlsplit - -import pytest - -from homeassistant.components.smartthings.audio import ( - FFMPEG_MAX_DURATION_SECONDS, - MAX_STORED_ENTRIES, - PCM_CHANNELS, - PCM_MIME, - PCM_SAMPLE_RATE, - PCM_SAMPLE_WIDTH, - TRANSCODE_TIMEOUT_SECONDS, - WARNING_DURATION_SECONDS, - SmartThingsAudioError, - async_get_audio_manager, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.network import NoURLAvailableError - -from tests.typing import ClientSessionGenerator - - -class _FakeProcess: - """Async subprocess stand-in that provides communicate.""" - - def __init__(self, stdout: bytes, stderr: bytes, returncode: int) -> None: - self._stdout = stdout - self._stderr = stderr - self.returncode = returncode - self.killed = False - - async def communicate(self) -> tuple[bytes, bytes]: - return self._stdout, self._stderr - - def kill(self) -> None: - self.killed = True - - -def _build_pcm( - duration_seconds: float = 1.0, - *, - sample_rate: int = PCM_SAMPLE_RATE, - sample_width: int = PCM_SAMPLE_WIDTH, - channels: int = PCM_CHANNELS, -) -> bytes: - """Generate silent raw PCM bytes for testing.""" - frame_count = int(sample_rate * duration_seconds) - return b"\x00" * frame_count * sample_width * channels - - -async def test_prepare_notification_creates_url( - hass: HomeAssistant, - hass_client_no_auth: ClientSessionGenerator, -) -> None: - """Ensure PCM proxy URLs are generated and served.""" - - hass.config.external_url = "https://example.com" - manager = await async_get_audio_manager(hass) - - pcm_bytes = _build_pcm() - - with patch.object( - manager, "_transcode_to_pcm", AsyncMock(return_value=(pcm_bytes, 1.0, False)) - ): - url = await manager.async_prepare_notification("https://example.com/source.mp3") - - parsed = urlsplit(url) - assert parsed.path.endswith(".pcm") - assert not parsed.query - - client = await hass_client_no_auth() - response = await client.get(parsed.path) - assert response.status == 200 - assert response.headers["Content-Type"] == PCM_MIME - assert response.headers["Cache-Control"] == "no-store" - body = await response.read() - assert body == pcm_bytes - - -@pytest.mark.asyncio -async def test_prepare_notification_uses_internal_url_when_external_missing( - hass: HomeAssistant, -) -> None: - """Fallback to the internal URL if no external URL is available.""" - - hass.config.external_url = None - hass.config.internal_url = "http://homeassistant.local:8123" - manager = await async_get_audio_manager(hass) - - pcm_bytes = _build_pcm() - - with patch.object( - manager, "_transcode_to_pcm", AsyncMock(return_value=(pcm_bytes, 1.0, False)) - ): - url = await manager.async_prepare_notification("https://example.com/source.mp3") - - parsed = urlsplit(url) - assert parsed.scheme == "http" - assert parsed.netloc == "homeassistant.local:8123" - assert parsed.path.endswith(".pcm") - - -@pytest.mark.asyncio -async def test_prepare_notification_requires_accessible_url( - hass: HomeAssistant, -) -> None: - """Fail if neither external nor internal URLs are available.""" - - hass.config.external_url = None - hass.config.internal_url = None - manager = await async_get_audio_manager(hass) - - pcm_bytes = _build_pcm() - - with ( - patch.object( - manager, - "_transcode_to_pcm", - AsyncMock(return_value=(pcm_bytes, 1.0, False)), - ), - patch( - "homeassistant.components.smartthings.audio.get_url", - side_effect=NoURLAvailableError, - ) as mock_get_url, - pytest.raises(SmartThingsAudioError), - ): - await manager.async_prepare_notification("https://example.com/source.mp3") - - assert mock_get_url.called - # Stored entry should be cleaned up after failure so subsequent requests - # don't leak memory or serve stale audio. - assert not manager._entries - - -async def test_audio_view_returns_404_for_unknown_token( - hass: HomeAssistant, - hass_client_no_auth: ClientSessionGenerator, -) -> None: - """Unknown tokens should return 404.""" - - await async_get_audio_manager(hass) - client = await hass_client_no_auth() - response = await client.get("/api/smartthings/audio/invalid-token.pcm") - assert response.status == 404 - - -@pytest.mark.asyncio -async def test_prepare_notification_raises_when_transcode_empty( - hass: HomeAssistant, -) -> None: - """Transcoding empty audio results in an error.""" - - hass.config.external_url = "https://example.com" - manager = await async_get_audio_manager(hass) - - with ( - patch.object( - manager, "_transcode_to_pcm", AsyncMock(return_value=(b"", 0.0, False)) - ), - pytest.raises(SmartThingsAudioError, match="Converted audio is empty"), - ): - await manager.async_prepare_notification("https://example.com/source.mp3") - - -@pytest.mark.asyncio -async def test_prepare_notification_warns_when_duration_exceeds_max( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: - """Warn when transcoded audio exceeds the SmartThings duration limit.""" - - hass.config.external_url = "https://example.com" - manager = await async_get_audio_manager(hass) - - pcm_bytes = b"pcm" - caplog.set_level(logging.WARNING) - - with patch.object( - manager, - "_transcode_to_pcm", - AsyncMock(return_value=(pcm_bytes, FFMPEG_MAX_DURATION_SECONDS + 1.0, True)), - ): - await manager.async_prepare_notification("https://example.com/source.mp3") - - assert any("truncated" in record.message for record in caplog.records) - - -@pytest.mark.asyncio -async def test_prepare_notification_warns_when_duration_exceeds_warning( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: - """Warn when transcoded audio exceeds the SmartThings warning threshold.""" - - hass.config.external_url = "https://example.com" - manager = await async_get_audio_manager(hass) - - pcm_bytes = _build_pcm(duration_seconds=WARNING_DURATION_SECONDS + 1) - caplog.set_level(logging.WARNING) - - with patch.object( - manager, - "_transcode_to_pcm", - AsyncMock(return_value=(pcm_bytes, WARNING_DURATION_SECONDS + 1.0, False)), - ): - await manager.async_prepare_notification("https://example.com/source.mp3") - - assert any( - "playback over" in record.message and "truncated" not in record.message - for record in caplog.records - ) - - -@pytest.mark.asyncio -async def test_prepare_notification_regenerates_token_on_collision( - hass: HomeAssistant, -) -> None: - """Regenerate tokens when a collision is detected.""" - - hass.config.external_url = "https://example.com" - manager = await async_get_audio_manager(hass) - pcm_bytes = _build_pcm() - - with ( - patch.object( - manager, - "_transcode_to_pcm", - AsyncMock(return_value=(pcm_bytes, 1.0, False)), - ), - patch( - "homeassistant.components.smartthings.audio.secrets.token_urlsafe", - side_effect=["dup", "dup", "unique"], - ), - ): - url1 = await manager.async_prepare_notification( - "https://example.com/source.mp3" - ) - url2 = await manager.async_prepare_notification( - "https://example.com/source.mp3" - ) - - assert urlsplit(url1).path.endswith("/dup.pcm") - assert urlsplit(url2).path.endswith("/unique.pcm") - - -@pytest.mark.asyncio -async def test_prepare_notification_schedules_cleanup( - hass: HomeAssistant, -) -> None: - """Ensure cached entries are scheduled for cleanup.""" - - hass.config.external_url = "https://example.com" - manager = await async_get_audio_manager(hass) - - pcm_bytes = _build_pcm() - - with patch.object( - manager, - "_transcode_to_pcm", - AsyncMock(return_value=(pcm_bytes, 1.0, False)), - ): - await manager.async_prepare_notification("https://example.com/source.mp3") - - assert manager._cleanup_handle is not None - for entry in manager._entries.values(): - entry.expires = 0 - - manager._cleanup_callback() - - assert not manager._entries - - -@pytest.mark.asyncio -async def test_prepare_notification_caps_entry_count( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: - """Ensure cached entries are capped.""" - - hass.config.external_url = "https://example.com" - manager = await async_get_audio_manager(hass) - - pcm_bytes = _build_pcm() - caplog.set_level(logging.DEBUG) - - with patch.object( - manager, - "_transcode_to_pcm", - AsyncMock(return_value=(pcm_bytes, 1.0, False)), - ): - for _ in range(MAX_STORED_ENTRIES + 2): - await manager.async_prepare_notification("https://example.com/source.mp3") - - assert len(manager._entries) == MAX_STORED_ENTRIES - assert any( - "Dropped oldest SmartThings audio token" in record.message - for record in caplog.records - ) - - -@pytest.mark.asyncio -async def test_transcode_to_pcm_handles_missing_ffmpeg( - hass: HomeAssistant, -) -> None: - """Raise friendly error when ffmpeg is unavailable.""" - - manager = await async_get_audio_manager(hass) - - with ( - patch( - "homeassistant.components.smartthings.audio.ffmpeg.get_ffmpeg_manager", - return_value=SimpleNamespace(binary="ffmpeg"), - ), - patch( - "homeassistant.components.smartthings.audio.asyncio.create_subprocess_exec", - side_effect=FileNotFoundError, - ), - pytest.raises(SmartThingsAudioError, match="FFmpeg is required"), - ): - await manager._transcode_to_pcm("https://example.com/source.mp3") - - -@pytest.mark.asyncio -async def test_transcode_to_pcm_handles_process_failure( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: - """Raise when ffmpeg reports an error.""" - - manager = await async_get_audio_manager(hass) - caplog.set_level(logging.ERROR) - - fake_process = _FakeProcess(stdout=b"", stderr=b"boom", returncode=1) - - with ( - patch( - "homeassistant.components.smartthings.audio.ffmpeg.get_ffmpeg_manager", - return_value=SimpleNamespace(binary="ffmpeg"), - ), - patch( - "homeassistant.components.smartthings.audio.asyncio.create_subprocess_exec", - AsyncMock(return_value=fake_process), - ), - pytest.raises(SmartThingsAudioError, match="Unable to convert"), - ): - await manager._transcode_to_pcm("https://example.com/source.mp3") - - assert any("FFmpeg failed" in record.message for record in caplog.records) - - -@pytest.mark.asyncio -async def test_transcode_to_pcm_times_out_and_kills_process( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: - """Kill ffmpeg when the transcode times out.""" - - manager = await async_get_audio_manager(hass) - fake_process = _FakeProcess(stdout=b"\x00\x00", stderr=b"", returncode=0) - caplog.set_level(logging.WARNING) - - with ( - patch( - "homeassistant.components.smartthings.audio.ffmpeg.get_ffmpeg_manager", - return_value=SimpleNamespace(binary="ffmpeg"), - ), - patch( - "homeassistant.components.smartthings.audio.asyncio.create_subprocess_exec", - AsyncMock(return_value=fake_process), - ), - patch( - "homeassistant.components.smartthings.audio.asyncio.wait_for", - side_effect=TimeoutError, - ), - ): - pcm, duration, truncated = await manager._transcode_to_pcm( - "https://example.com/source.mp3" - ) - - assert fake_process.killed is True - assert pcm == b"\x00\x00" - assert duration == pytest.approx(1 / PCM_SAMPLE_RATE) - assert truncated is False - assert any("FFmpeg timed out" in record.message for record in caplog.records) - - -@pytest.mark.asyncio -async def test_transcode_to_pcm_returns_empty_audio( - hass: HomeAssistant, -) -> None: - """Return empty payload when ffmpeg produced nothing.""" - - manager = await async_get_audio_manager(hass) - fake_process = _FakeProcess(stdout=b"", stderr=b"", returncode=0) - - with ( - patch( - "homeassistant.components.smartthings.audio.ffmpeg.get_ffmpeg_manager", - return_value=SimpleNamespace(binary="ffmpeg"), - ), - patch( - "homeassistant.components.smartthings.audio.asyncio.create_subprocess_exec", - AsyncMock(return_value=fake_process), - ) as mock_exec, - ): - pcm, duration, truncated = await manager._transcode_to_pcm( - "https://example.com/source.mp3" - ) - - assert pcm == b"" - assert duration == 0.0 - assert truncated is False - mock_exec.assert_awaited_once() - - -@pytest.mark.asyncio -async def test_transcode_to_pcm_enforces_duration_cap( - hass: HomeAssistant, -) -> None: - """Ensure ffmpeg is instructed to limit duration and timeout is enforced.""" - - manager = await async_get_audio_manager(hass) - pcm_bytes = _build_pcm(duration_seconds=FFMPEG_MAX_DURATION_SECONDS) - fake_process = _FakeProcess(stdout=pcm_bytes, stderr=b"", returncode=0) - - timeouts: list[float] = [] - original_wait_for = asyncio.wait_for - - async def _wait_for(awaitable, timeout): - timeouts.append(timeout) - return await original_wait_for(awaitable, timeout) - - mock_exec = AsyncMock(return_value=fake_process) - - with ( - patch( - "homeassistant.components.smartthings.audio.ffmpeg.get_ffmpeg_manager", - return_value=SimpleNamespace(binary="ffmpeg"), - ), - patch( - "homeassistant.components.smartthings.audio.asyncio.create_subprocess_exec", - mock_exec, - ), - patch( - "homeassistant.components.smartthings.audio.asyncio.wait_for", - new=_wait_for, - ), - ): - pcm, duration, truncated = await manager._transcode_to_pcm( - "https://example.com/source.mp3" - ) - - command = list(mock_exec.await_args.args) - assert "-t" in command - assert command[command.index("-t") + 1] == str(FFMPEG_MAX_DURATION_SECONDS) - assert timeouts == [TRANSCODE_TIMEOUT_SECONDS] - assert pcm == pcm_bytes - assert duration == pytest.approx(FFMPEG_MAX_DURATION_SECONDS) - assert truncated is True - - -async def test_transcode_to_pcm_logs_misaligned_pcm( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: - """Log debug output when ffmpeg output contains a partial frame.""" - - manager = await async_get_audio_manager(hass) - caplog.set_level(logging.DEBUG) - - pcm_bytes = _build_pcm() + b"\xaa" - fake_process = _FakeProcess(stdout=pcm_bytes, stderr=b"", returncode=0) - - with ( - patch( - "homeassistant.components.smartthings.audio.ffmpeg.get_ffmpeg_manager", - return_value=SimpleNamespace(binary="ffmpeg"), - ), - patch( - "homeassistant.components.smartthings.audio.asyncio.create_subprocess_exec", - AsyncMock(return_value=fake_process), - ), - ): - pcm, duration, truncated = await manager._transcode_to_pcm( - "https://example.com/source.mp3" - ) - - assert pcm == _build_pcm() - assert duration > 0 - assert truncated is False - assert any("misaligned PCM" in record.message for record in caplog.records) - - -@pytest.mark.asyncio -async def test_transcode_to_pcm_drops_partial_frame_payload( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: - """Drop audio entirely when ffmpeg returns fewer bytes than a full frame.""" - - manager = await async_get_audio_manager(hass) - caplog.set_level(logging.DEBUG) - - fake_process = _FakeProcess(stdout=b"\x00", stderr=b"", returncode=0) - - with ( - patch( - "homeassistant.components.smartthings.audio.ffmpeg.get_ffmpeg_manager", - return_value=SimpleNamespace(binary="ffmpeg"), - ), - patch( - "homeassistant.components.smartthings.audio.asyncio.create_subprocess_exec", - AsyncMock(return_value=fake_process), - ), - ): - pcm, duration, truncated = await manager._transcode_to_pcm( - "https://example.com/source.mp3" - ) - - assert pcm == b"" - assert duration == 0.0 - assert truncated is False - assert any("misaligned PCM" in record.message for record in caplog.records) diff --git a/tests/components/smartthings/test_media_player.py b/tests/components/smartthings/test_media_player.py index 84c13c5485e551..0fb53e642d4ce7 100644 --- a/tests/components/smartthings/test_media_player.py +++ b/tests/components/smartthings/test_media_player.py @@ -1,7 +1,6 @@ """Test for the SmartThings media player platform.""" -from types import SimpleNamespace -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock from pysmartthings import Attribute, Capability, Command, Status from pysmartthings.models import HealthStatus @@ -10,19 +9,14 @@ from homeassistant.components.media_player import ( ATTR_INPUT_SOURCE, - ATTR_MEDIA_CONTENT_ID, - ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_REPEAT, ATTR_MEDIA_SHUFFLE, ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, DOMAIN as MEDIA_PLAYER_DOMAIN, - SERVICE_PLAY_MEDIA, SERVICE_SELECT_SOURCE, - MediaType, RepeatMode, ) -from homeassistant.components.smartthings.audio import SmartThingsAudioError from homeassistant.components.smartthings.const import MAIN from homeassistant.const import ( ATTR_ENTITY_ID, @@ -45,7 +39,6 @@ Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import ( @@ -205,176 +198,6 @@ async def test_volume_down( ) -@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) -async def test_play_media_notification( - hass: HomeAssistant, - devices: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test playing media via SmartThings audio notification.""" - - await setup_integration(hass, mock_config_entry) - - manager = AsyncMock() - manager.async_prepare_notification.return_value = "https://example.com/audio.pcm" - - with ( - patch( - "homeassistant.components.smartthings.media_player.async_get_audio_manager", - AsyncMock(return_value=manager), - ), - patch( - "homeassistant.components.smartthings.media_player.async_process_play_media_url", - return_value="https://example.com/source.mp3", - ), - ): - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_PLAY_MEDIA, - { - ATTR_ENTITY_ID: "media_player.soundbar", - ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, - ATTR_MEDIA_CONTENT_ID: "https://example.com/source.mp3", - }, - blocking=True, - ) - - expected_command = Command("playTrackAndResume") - devices.execute_device_command.assert_called_once_with( - "afcf3b91-0000-1111-2222-ddff2a0a6577", - Capability.AUDIO_NOTIFICATION, - expected_command, - MAIN, - argument=["https://example.com/audio.pcm"], - ) - - -@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) -async def test_play_media_requires_audio_notification_capability( - hass: HomeAssistant, - devices: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Expect an error if the device lacks audio notification support.""" - - devices.get_device_status.return_value[MAIN].pop( - Capability.AUDIO_NOTIFICATION, None - ) - - await setup_integration(hass, mock_config_entry) - - entity = hass.data["entity_components"][MEDIA_PLAYER_DOMAIN].get_entity( - "media_player.soundbar" - ) - assert entity is not None - - with pytest.raises( - HomeAssistantError, match="Device does not support audio notifications" - ): - await entity.async_play_media(MediaType.MUSIC, "https://example.com/source.mp3") - - -@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) -async def test_play_media_rejects_unsupported_media_type( - hass: HomeAssistant, - devices: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Unsupported media types should raise an error.""" - - await setup_integration(hass, mock_config_entry) - - entity = hass.data["entity_components"][MEDIA_PLAYER_DOMAIN].get_entity( - "media_player.soundbar" - ) - assert entity is not None - - with pytest.raises( - HomeAssistantError, match="Unsupported media type for SmartThings audio" - ): - await entity.async_play_media( - MediaType.TVSHOW, "https://example.com/source.mp3" - ) - - -@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) -async def test_play_media_uses_media_source_resolution( - hass: HomeAssistant, - devices: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Media source IDs are resolved and processed before playback.""" - - await setup_integration(hass, mock_config_entry) - - manager = AsyncMock() - manager.async_prepare_notification.return_value = "https://example.com/audio.pcm" - - with ( - patch( - "homeassistant.components.smartthings.media_player.async_get_audio_manager", - AsyncMock(return_value=manager), - ), - patch( - "homeassistant.components.smartthings.media_player.async_process_play_media_url", - return_value="https://example.com/processed.mp3", - ) as mock_process, - patch( - "homeassistant.components.smartthings.media_player.media_source.is_media_source_id", - return_value=True, - ) as mock_is_media, - patch( - "homeassistant.components.smartthings.media_player.media_source.async_resolve_media", - AsyncMock( - return_value=SimpleNamespace(url="https://example.com/from_source") - ), - ) as mock_resolve, - ): - entity = hass.data["entity_components"][MEDIA_PLAYER_DOMAIN].get_entity( - "media_player.soundbar" - ) - assert entity is not None - - await entity.async_play_media(MediaType.MUSIC, "media-source://foo") - - mock_is_media.assert_called_once() - mock_resolve.assert_called_once() - mock_process.assert_called_with(hass, "https://example.com/from_source") - devices.execute_device_command.assert_called_once() - - -@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) -async def test_play_media_wraps_audio_errors( - hass: HomeAssistant, - devices: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """SmartThings audio errors propagate as HomeAssistantError.""" - - await setup_integration(hass, mock_config_entry) - - manager = AsyncMock() - manager.async_prepare_notification.side_effect = SmartThingsAudioError("boom") - - entity = hass.data["entity_components"][MEDIA_PLAYER_DOMAIN].get_entity( - "media_player.soundbar" - ) - assert entity is not None - - with ( - patch( - "homeassistant.components.smartthings.media_player.async_get_audio_manager", - AsyncMock(return_value=manager), - ), - patch( - "homeassistant.components.smartthings.media_player.async_process_play_media_url", - return_value="https://example.com/source.mp3", - ), - pytest.raises(HomeAssistantError, match="boom"), - ): - await entity.async_play_media(MediaType.MUSIC, "https://example.com/source.mp3") - - @pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) async def test_media_play( hass: HomeAssistant, diff --git a/tests/components/unifiprotect/test_number.py b/tests/components/unifiprotect/test_number.py index d308d0199d7c66..7ccb1705964c98 100644 --- a/tests/components/unifiprotect/test_number.py +++ b/tests/components/unifiprotect/test_number.py @@ -3,10 +3,10 @@ from __future__ import annotations from datetime import timedelta -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock import pytest -from uiprotect.data import Camera, Doorlock, IRLEDMode, Light +from uiprotect.data import Camera, Chime, Doorlock, IRLEDMode, Light, RingSetting from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION from homeassistant.components.unifiprotect.number import ( @@ -264,3 +264,140 @@ async def test_number_lock_auto_close( ) mock_method.assert_called_once_with(timedelta(seconds=15.0)) + + +def _setup_chime_with_doorbell( + chime: Chime, doorbell: Camera, volume: int = 50 +) -> None: + """Set up chime with paired doorbell for testing.""" + chime.camera_ids = [doorbell.id] + chime.ring_settings = [ + RingSetting( + camera_id=doorbell.id, + repeat_times=1, + ringtone_id="test-ringtone-id", + volume=volume, + ) + ] + + +async def test_chime_ring_volume_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + chime: Chime, + doorbell: Camera, +) -> None: + """Test chime ring volume number entity setup.""" + _setup_chime_with_doorbell(chime, doorbell, volume=75) + + await init_entry(hass, ufp, [chime, doorbell], regenerate_ids=False) + + entity_id = "number.test_chime_ring_volume_test_camera" + entity = entity_registry.async_get(entity_id) + assert entity is not None + assert entity.unique_id == f"{chime.mac}_ring_volume_{doorbell.id}" + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "75" + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + +async def test_chime_ring_volume_set_value( + hass: HomeAssistant, + ufp: MockUFPFixture, + chime: Chime, + doorbell: Camera, +) -> None: + """Test setting chime ring volume.""" + _setup_chime_with_doorbell(chime, doorbell) + + await init_entry(hass, ufp, [chime, doorbell], regenerate_ids=False) + + entity_id = "number.test_chime_ring_volume_test_camera" + + with patch_ufp_method( + chime, "set_volume_for_camera_public", new_callable=AsyncMock + ) as mock_method: + await hass.services.async_call( + "number", + "set_value", + {ATTR_ENTITY_ID: entity_id, "value": 80.0}, + blocking=True, + ) + + mock_method.assert_called_once_with(doorbell, 80) + + +async def test_chime_ring_volume_multiple_cameras( + hass: HomeAssistant, + ufp: MockUFPFixture, + chime: Chime, + doorbell: Camera, +) -> None: + """Test chime ring volume with multiple paired cameras.""" + doorbell2 = doorbell.model_copy() + doorbell2.id = "test-doorbell-2" + doorbell2.name = "Test Doorbell 2" + doorbell2.mac = "aa:bb:cc:dd:ee:02" + + chime.camera_ids = [doorbell.id, doorbell2.id] + chime.ring_settings = [ + RingSetting( + camera_id=doorbell.id, + repeat_times=1, + ringtone_id="test-ringtone-id", + volume=60, + ), + RingSetting( + camera_id=doorbell2.id, + repeat_times=2, + ringtone_id="test-ringtone-id-2", + volume=80, + ), + ] + + await init_entry(hass, ufp, [chime, doorbell, doorbell2], regenerate_ids=False) + + state1 = hass.states.get("number.test_chime_ring_volume_test_camera") + assert state1 is not None + assert state1.state == "60" + + state2 = hass.states.get("number.test_chime_ring_volume_test_doorbell_2") + assert state2 is not None + assert state2.state == "80" + + +async def test_chime_ring_volume_unavailable_when_unpaired( + hass: HomeAssistant, + ufp: MockUFPFixture, + chime: Chime, + doorbell: Camera, +) -> None: + """Test chime ring volume becomes unavailable when camera is unpaired.""" + _setup_chime_with_doorbell(chime, doorbell) + + await init_entry(hass, ufp, [chime, doorbell], regenerate_ids=False) + + entity_id = "number.test_chime_ring_volume_test_camera" + state = hass.states.get(entity_id) + assert state + assert state.state == "50" + + # Simulate removing the camera pairing + new_chime = chime.model_copy() + new_chime.ring_settings = [] + + ufp.api.bootstrap.chimes = {new_chime.id: new_chime} + ufp.api.bootstrap.nvr.system_info.ustorage = None + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = new_chime + + ufp.ws_msg(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == "unavailable" diff --git a/tests/components/unifiprotect/test_services.py b/tests/components/unifiprotect/test_services.py index f08e7157b83c88..23db8df4fe6a48 100644 --- a/tests/components/unifiprotect/test_services.py +++ b/tests/components/unifiprotect/test_services.py @@ -260,7 +260,6 @@ async def test_remove_privacy_zone( assert not doorbell.privacy_zones -@pytest.mark.asyncio async def get_user_keyring_info( hass: HomeAssistant, entity_registry: er.EntityRegistry, diff --git a/tests/components/wled/test_analytics.py b/tests/components/wled/test_analytics.py index 7b392c22180ae5..fd4feb5f53aec5 100644 --- a/tests/components/wled/test_analytics.py +++ b/tests/components/wled/test_analytics.py @@ -1,7 +1,5 @@ """Tests for analytics platform.""" -import pytest - from homeassistant.components.analytics import async_devices_payload from homeassistant.components.wled import DOMAIN from homeassistant.core import HomeAssistant @@ -11,7 +9,6 @@ from tests.common import MockConfigEntry -@pytest.mark.asyncio async def test_analytics( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: