From b09927bb530b87cce8d2b7efe7b70670f76a34e4 Mon Sep 17 00:00:00 2001 From: Jordan Harvey Date: Fri, 24 Oct 2025 11:13:41 +0100 Subject: [PATCH 01/17] Add shared BleakScanner to probe_plus (#155051) --- homeassistant/components/probe_plus/coordinator.py | 13 +++++++++++++ homeassistant/components/probe_plus/manifest.json | 2 +- homeassistant/components/probe_plus/strings.json | 5 +++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 21 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/probe_plus/coordinator.py b/homeassistant/components/probe_plus/coordinator.py index b712e3fc84bf0..1e37340726b1d 100644 --- a/homeassistant/components/probe_plus/coordinator.py +++ b/homeassistant/components/probe_plus/coordinator.py @@ -8,11 +8,15 @@ from pyprobeplus import ProbePlusDevice from pyprobeplus.exceptions import ProbePlusDeviceNotFound, ProbePlusError +from homeassistant.components import bluetooth from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from .const import DOMAIN + type ProbePlusConfigEntry = ConfigEntry[ProbePlusDataUpdateCoordinator] _LOGGER = logging.getLogger(__name__) @@ -39,8 +43,17 @@ def __init__(self, hass: HomeAssistant, entry: ProbePlusConfigEntry) -> None: config_entry=entry, ) + available_scanners = bluetooth.async_scanner_count(hass, connectable=True) + + if available_scanners == 0: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="no_bleak_scanner", + ) + self.device: ProbePlusDevice = ProbePlusDevice( address_or_ble_device=entry.data[CONF_ADDRESS], + scanner=bluetooth.async_get_scanner(hass), name=entry.title, notify_callback=self.async_update_listeners, ) diff --git a/homeassistant/components/probe_plus/manifest.json b/homeassistant/components/probe_plus/manifest.json index 01a0d94b15385..160c231897c20 100644 --- a/homeassistant/components/probe_plus/manifest.json +++ b/homeassistant/components/probe_plus/manifest.json @@ -15,5 +15,5 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "bronze", - "requirements": ["pyprobeplus==1.1.1"] + "requirements": ["pyprobeplus==1.1.2"] } diff --git a/homeassistant/components/probe_plus/strings.json b/homeassistant/components/probe_plus/strings.json index 45fd4be39ce0c..bd2ccac851463 100644 --- a/homeassistant/components/probe_plus/strings.json +++ b/homeassistant/components/probe_plus/strings.json @@ -45,5 +45,10 @@ "name": "Relay voltage" } } + }, + "exceptions": { + "no_bleak_scanner": { + "message": "No compatible Bluetooth scanner found." + } } } diff --git a/requirements_all.txt b/requirements_all.txt index bd89be0ef56b6..f22b39f6d7c30 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2305,7 +2305,7 @@ pypoint==3.0.0 pyportainer==1.0.4 # homeassistant.components.probe_plus -pyprobeplus==1.1.1 +pyprobeplus==1.1.2 # homeassistant.components.profiler pyprof2calltree==1.4.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5c481dcfc3bb0..68fa05c738a18 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1932,7 +1932,7 @@ pypoint==3.0.0 pyportainer==1.0.4 # homeassistant.components.probe_plus -pyprobeplus==1.1.1 +pyprobeplus==1.1.2 # homeassistant.components.profiler pyprof2calltree==1.4.5 From 38620a0cda8a7cb4e9f36461c0d3303c6dfb3369 Mon Sep 17 00:00:00 2001 From: James <38914183+barneyonline@users.noreply.github.com> Date: Fri, 24 Oct 2025 21:57:44 +1100 Subject: [PATCH 02/17] Yardian: add binary sensors (#152654) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/yardian/__init__.py | 5 +- .../components/yardian/binary_sensor.py | 133 ++++++++++ .../components/yardian/coordinator.py | 61 ++++- homeassistant/components/yardian/icons.json | 20 ++ homeassistant/components/yardian/strings.json | 16 ++ tests/components/yardian/__init__.py | 12 + tests/components/yardian/conftest.py | 41 +++ .../yardian/snapshots/test_binary_sensor.ambr | 243 ++++++++++++++++++ .../components/yardian/test_binary_sensor.py | 50 ++++ tests/components/yardian/test_init.py | 27 ++ 10 files changed, 595 insertions(+), 13 deletions(-) create mode 100644 homeassistant/components/yardian/binary_sensor.py create mode 100644 tests/components/yardian/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/yardian/test_binary_sensor.py create mode 100644 tests/components/yardian/test_init.py diff --git a/homeassistant/components/yardian/__init__.py b/homeassistant/components/yardian/__init__.py index eafdc8e68db7c..7942d77ac3fc7 100644 --- a/homeassistant/components/yardian/__init__.py +++ b/homeassistant/components/yardian/__init__.py @@ -12,7 +12,10 @@ from .const import DOMAIN from .coordinator import YardianUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.SWITCH] +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.SWITCH, +] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/yardian/binary_sensor.py b/homeassistant/components/yardian/binary_sensor.py new file mode 100644 index 0000000000000..e9afa44662b28 --- /dev/null +++ b/homeassistant/components/yardian/binary_sensor.py @@ -0,0 +1,133 @@ +"""Binary sensors for Yardian integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import YardianUpdateCoordinator + + +@dataclass(kw_only=True, frozen=True) +class YardianBinarySensorEntityDescription(BinarySensorEntityDescription): + """Entity description for Yardian binary sensors.""" + + value_fn: Callable[[YardianUpdateCoordinator], bool | None] + + +def _zone_enabled_value( + coordinator: YardianUpdateCoordinator, zone_id: int +) -> bool | None: + """Return True if zone is enabled on controller.""" + try: + return coordinator.data.zones[zone_id][1] == 1 + except (IndexError, TypeError): + return None + + +def _zone_value_factory( + zone_id: int, +) -> Callable[[YardianUpdateCoordinator], bool | None]: + """Return a callable evaluating whether a zone is enabled.""" + + def value(coordinator: YardianUpdateCoordinator) -> bool | None: + return _zone_enabled_value(coordinator, zone_id) + + return value + + +SENSOR_DESCRIPTIONS: tuple[YardianBinarySensorEntityDescription, ...] = ( + YardianBinarySensorEntityDescription( + key="watering_running", + translation_key="watering_running", + device_class=BinarySensorDeviceClass.RUNNING, + value_fn=lambda coordinator: bool(coordinator.data.active_zones), + ), + YardianBinarySensorEntityDescription( + key="standby", + translation_key="standby", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda coordinator: bool( + coordinator.data.oper_info.get("iStandby", 0) + ), + ), + YardianBinarySensorEntityDescription( + key="freeze_prevent", + translation_key="freeze_prevent", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda coordinator: bool( + coordinator.data.oper_info.get("fFreezePrevent", 0) + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Yardian binary sensors.""" + coordinator: YardianUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[BinarySensorEntity] = [ + YardianBinarySensor(coordinator, description) + for description in SENSOR_DESCRIPTIONS + ] + + zone_descriptions = [ + YardianBinarySensorEntityDescription( + key=f"zone_enabled_{zone_id}", + translation_key="zone_enabled", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=_zone_value_factory(zone_id), + translation_placeholders={"zone": str(zone_id + 1)}, + ) + for zone_id in range(len(coordinator.data.zones)) + ] + + entities.extend( + YardianBinarySensor(coordinator, description) + for description in zone_descriptions + ) + + async_add_entities(entities) + + +class YardianBinarySensor( + CoordinatorEntity[YardianUpdateCoordinator], BinarySensorEntity +): + """Representation of a Yardian binary sensor based on a description.""" + + entity_description: YardianBinarySensorEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + coordinator: YardianUpdateCoordinator, + description: YardianBinarySensorEntityDescription, + ) -> None: + """Initialize the Yardian binary sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.yid}-{description.key}" + self._attr_device_info = coordinator.device_info + + @property + def is_on(self) -> bool | None: + """Return the current state based on the description's value function.""" + return self.entity_description.value_fn(self.coordinator) diff --git a/homeassistant/components/yardian/coordinator.py b/homeassistant/components/yardian/coordinator.py index da016ca4ec4e1..6a45c7012b225 100644 --- a/homeassistant/components/yardian/coordinator.py +++ b/homeassistant/components/yardian/coordinator.py @@ -6,15 +6,12 @@ import datetime import logging -from pyyardian import ( - AsyncYardianClient, - NetworkException, - NotAuthorizedException, - YardianDeviceState, -) +from pyyardian import AsyncYardianClient, NetworkException, NotAuthorizedException +from pyyardian.typing import OperationInfo from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -25,7 +22,22 @@ SCAN_INTERVAL = datetime.timedelta(seconds=30) -class YardianUpdateCoordinator(DataUpdateCoordinator[YardianDeviceState]): +class YardianCombinedState: + """Combined device state for Yardian.""" + + def __init__( + self, + zones: list[list], + active_zones: set[int], + oper_info: OperationInfo, + ) -> None: + """Initialize combined state with zones, active_zones and oper_info.""" + self.zones = zones + self.active_zones = active_zones + self.oper_info = oper_info + + +class YardianUpdateCoordinator(DataUpdateCoordinator[YardianCombinedState]): """Coordinator for Yardian API calls.""" config_entry: ConfigEntry @@ -50,6 +62,7 @@ def __init__( self.yid = entry.data["yid"] self._name = entry.title self._model = entry.data["model"] + self._serial = entry.data.get("serialNumber") @property def device_info(self) -> DeviceInfo: @@ -59,17 +72,41 @@ def device_info(self) -> DeviceInfo: identifiers={(DOMAIN, self.yid)}, manufacturer=MANUFACTURER, model=self._model, + serial_number=self._serial, ) - async def _async_update_data(self) -> YardianDeviceState: + async def _async_update_data(self) -> YardianCombinedState: """Fetch data from Yardian device.""" try: async with asyncio.timeout(10): - return await self.controller.fetch_device_state() + _LOGGER.debug( + "Fetching Yardian device state for %s (controller=%s)", + self._name, + type(self.controller).__name__, + ) + # Fetch device state and operation info; specific exceptions are + # handled by the outer block to avoid double-logging. + dev_state = await self.controller.fetch_device_state() + oper_info = await self.controller.fetch_oper_info() + oper_keys = list(oper_info.keys()) if hasattr(oper_info, "keys") else [] + _LOGGER.debug( + "Fetched Yardian data: zones=%s active=%s oper_keys=%s", + len(getattr(dev_state, "zones", [])), + len(getattr(dev_state, "active_zones", [])), + oper_keys, + ) + return YardianCombinedState( + zones=dev_state.zones, + active_zones=dev_state.active_zones, + oper_info=oper_info, + ) except TimeoutError as e: - raise UpdateFailed("Communication with Device was time out") from e + raise UpdateFailed("Timeout communicating with device") from e except NotAuthorizedException as e: - raise UpdateFailed("Invalid access token") from e + raise ConfigEntryError("Invalid access token") from e except NetworkException as e: - raise UpdateFailed("Failed to communicate with Device") from e + raise UpdateFailed("Failed to communicate with device") from e + except Exception as e: # safety net for tests to surface failure reason + _LOGGER.exception("Unexpected error while fetching Yardian data") + raise UpdateFailed(f"Unexpected error: {type(e).__name__}: {e}") from e diff --git a/homeassistant/components/yardian/icons.json b/homeassistant/components/yardian/icons.json index 4ca3d83bd158c..05b0ecb388118 100644 --- a/homeassistant/components/yardian/icons.json +++ b/homeassistant/components/yardian/icons.json @@ -4,6 +4,26 @@ "switch": { "default": "mdi:water" } + }, + "binary_sensor": { + "watering_running": { + "default": "mdi:sprinkler", + "state": { + "off": "mdi:sprinkler-variant" + } + }, + "standby": { + "default": "mdi:pause-circle" + }, + "freeze_prevent": { + "default": "mdi:snowflake-alert" + }, + "zone_enabled": { + "default": "mdi:toggle-switch", + "state": { + "off": "mdi:toggle-switch-off" + } + } } }, "services": { diff --git a/homeassistant/components/yardian/strings.json b/homeassistant/components/yardian/strings.json index fcaef65ee3ef4..226285e1e952d 100644 --- a/homeassistant/components/yardian/strings.json +++ b/homeassistant/components/yardian/strings.json @@ -20,6 +20,22 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, + "entity": { + "binary_sensor": { + "watering_running": { + "name": "Watering running" + }, + "standby": { + "name": "Standby" + }, + "freeze_prevent": { + "name": "Freeze prevent" + }, + "zone_enabled": { + "name": "Zone {zone} enabled" + } + } + }, "services": { "start_irrigation": { "name": "Start irrigation", diff --git a/tests/components/yardian/__init__.py b/tests/components/yardian/__init__.py index 47f8cbc509e09..2f8936b7cae66 100644 --- a/tests/components/yardian/__init__.py +++ b/tests/components/yardian/__init__.py @@ -1 +1,13 @@ """Tests for the yardian integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Method for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/yardian/conftest.py b/tests/components/yardian/conftest.py index 00e76c4c34ff3..fa85caffea5a4 100644 --- a/tests/components/yardian/conftest.py +++ b/tests/components/yardian/conftest.py @@ -4,6 +4,12 @@ from unittest.mock import AsyncMock, patch import pytest +from pyyardian import OperationInfo, YardianDeviceState + +from homeassistant.components.yardian import DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_NAME + +from tests.common import MockConfigEntry @pytest.fixture @@ -13,3 +19,38 @@ def mock_setup_entry() -> Generator[AsyncMock]: "homeassistant.components.yardian.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Define a mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="yid123", + data={ + CONF_HOST: "1.2.3.4", + CONF_ACCESS_TOKEN: "abc", + CONF_NAME: "Yardian", + "yid": "yid123", + "model": "PRO1902", + "serialNumber": "SN1", + }, + title="Yardian Smart Sprinkler", + ) + + +@pytest.fixture +def mock_yardian_client() -> Generator[AsyncMock]: + """Define a mocked Yardian client.""" + with patch( + "homeassistant.components.yardian.AsyncYardianClient", autospec=True + ) as mock_client_class: + mock_client = mock_client_class.return_value + mock_client.fetch_device_state.return_value = YardianDeviceState( + zones=[["Zone 1", 1], ["Zone 2", 2]], + active_zones={0}, + ) + mock_client.fetch_oper_info.return_value = OperationInfo( + iStandby=1, fFreezePrevent=1 + ) + yield mock_client diff --git a/tests/components/yardian/snapshots/test_binary_sensor.ambr b/tests/components/yardian/snapshots/test_binary_sensor.ambr new file mode 100644 index 0000000000000..a9373348d5a1f --- /dev/null +++ b/tests/components/yardian/snapshots/test_binary_sensor.ambr @@ -0,0 +1,243 @@ +# serializer version: 1 +# name: test_all_entities[binary_sensor.yardian_smart_sprinkler_freeze_prevent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.yardian_smart_sprinkler_freeze_prevent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Freeze prevent', + 'platform': 'yardian', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'freeze_prevent', + 'unique_id': 'yid123-freeze_prevent', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.yardian_smart_sprinkler_freeze_prevent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Yardian Smart Sprinkler Freeze prevent', + }), + 'context': , + 'entity_id': 'binary_sensor.yardian_smart_sprinkler_freeze_prevent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.yardian_smart_sprinkler_standby-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.yardian_smart_sprinkler_standby', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Standby', + 'platform': 'yardian', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'standby', + 'unique_id': 'yid123-standby', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.yardian_smart_sprinkler_standby-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Yardian Smart Sprinkler Standby', + }), + 'context': , + 'entity_id': 'binary_sensor.yardian_smart_sprinkler_standby', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.yardian_smart_sprinkler_watering_running-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.yardian_smart_sprinkler_watering_running', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Watering running', + 'platform': 'yardian', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'watering_running', + 'unique_id': 'yid123-watering_running', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.yardian_smart_sprinkler_watering_running-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Yardian Smart Sprinkler Watering running', + }), + 'context': , + 'entity_id': 'binary_sensor.yardian_smart_sprinkler_watering_running', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.yardian_smart_sprinkler_zone_1_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.yardian_smart_sprinkler_zone_1_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Zone 1 enabled', + 'platform': 'yardian', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'zone_enabled', + 'unique_id': 'yid123-zone_enabled_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.yardian_smart_sprinkler_zone_1_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Yardian Smart Sprinkler Zone 1 enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.yardian_smart_sprinkler_zone_1_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.yardian_smart_sprinkler_zone_2_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.yardian_smart_sprinkler_zone_2_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Zone 2 enabled', + 'platform': 'yardian', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'zone_enabled', + 'unique_id': 'yid123-zone_enabled_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.yardian_smart_sprinkler_zone_2_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Yardian Smart Sprinkler Zone 2 enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.yardian_smart_sprinkler_zone_2_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/yardian/test_binary_sensor.py b/tests/components/yardian/test_binary_sensor.py new file mode 100644 index 0000000000000..61f6dbce5de4d --- /dev/null +++ b/tests/components/yardian/test_binary_sensor.py @@ -0,0 +1,50 @@ +"""Validate Yardian binary sensor behavior.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_yardian_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.yardian.PLATFORMS", [Platform.BINARY_SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_zone_enabled_sensors_disabled_by_default( + hass: HomeAssistant, + mock_yardian_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """All per-zone binary sensors remain disabled by default.""" + + await setup_integration(hass, mock_config_entry) + + for idx in range(1, 3): + entity_entry = entity_registry.async_get( + f"binary_sensor.yardian_smart_sprinkler_zone_{idx}_enabled" + ) + assert entity_entry is not None + assert entity_entry.disabled + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/yardian/test_init.py b/tests/components/yardian/test_init.py new file mode 100644 index 0000000000000..c798faf0adb8a --- /dev/null +++ b/tests/components/yardian/test_init.py @@ -0,0 +1,27 @@ +"""Test the initialization of Yardian.""" + +from unittest.mock import AsyncMock + +from pyyardian import NotAuthorizedException + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_setup_unauthorized( + hass: HomeAssistant, + mock_yardian_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test setup when unauthorized.""" + mock_yardian_client.fetch_device_state.side_effect = NotAuthorizedException + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR From 53984f7abcee9d36e8b9d7058c92b1cd5e49602f Mon Sep 17 00:00:00 2001 From: Retha Runolfsson <137745329+zerzhang@users.noreply.github.com> Date: Fri, 24 Oct 2025 18:57:57 +0800 Subject: [PATCH 03/17] Bump PySwitchbot to 0.72.0 (#155073) --- homeassistant/components/switchbot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 2d741c1301bc8..27f5e46821e34 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -41,5 +41,5 @@ "iot_class": "local_push", "loggers": ["switchbot"], "quality_scale": "gold", - "requirements": ["PySwitchbot==0.71.0"] + "requirements": ["PySwitchbot==0.72.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index f22b39f6d7c30..73e68a57575f5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -84,7 +84,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.71.0 +PySwitchbot==0.72.0 # homeassistant.components.switchmate PySwitchmate==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 68fa05c738a18..2d4bd4f68f973 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.71.0 +PySwitchbot==0.72.0 # homeassistant.components.syncthru PySyncThru==0.8.0 From b19070fa4b1e3e12e598bac2a1b18e17d7789e5b Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 24 Oct 2025 21:03:24 +1000 Subject: [PATCH 04/17] Fix history coordinator in Tesla Fleet and Teslemetry (#153068) Co-authored-by: Robert Resch --- homeassistant/components/tesla_fleet/coordinator.py | 7 +++++-- homeassistant/components/teslemetry/coordinator.py | 9 ++++++--- .../components/tesla_fleet/snapshots/test_sensor.ambr | 10 +++++----- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/tesla_fleet/coordinator.py b/homeassistant/components/tesla_fleet/coordinator.py index 4ab332c91525a..59d1c25070358 100644 --- a/homeassistant/components/tesla_fleet/coordinator.py +++ b/homeassistant/components/tesla_fleet/coordinator.py @@ -258,11 +258,14 @@ async def _async_update_data(self) -> dict[str, Any]: raise UpdateFailed("Received invalid data") # Add all time periods together - output = dict.fromkeys(ENERGY_HISTORY_FIELDS, 0) + output = dict.fromkeys(ENERGY_HISTORY_FIELDS, None) for period in data.get("time_series", []): for key in ENERGY_HISTORY_FIELDS: if key in period: - output[key] += period[key] + if output[key] is None: + output[key] = period[key] + else: + output[key] += period[key] return output diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py index eed00ebc64f80..a73424bdd288d 100644 --- a/homeassistant/components/teslemetry/coordinator.py +++ b/homeassistant/components/teslemetry/coordinator.py @@ -199,10 +199,13 @@ async def _async_update_data(self) -> dict[str, Any]: raise UpdateFailed("Received invalid data") # Add all time periods together - output = dict.fromkeys(ENERGY_HISTORY_FIELDS, 0) - for period in data["time_series"]: + output = dict.fromkeys(ENERGY_HISTORY_FIELDS, None) + for period in data.get("time_series", []): for key in ENERGY_HISTORY_FIELDS: if key in period: - output[key] += period[key] + if output[key] is None: + output[key] = period[key] + else: + output[key] += period[key] return output diff --git a/tests/components/tesla_fleet/snapshots/test_sensor.ambr b/tests/components/tesla_fleet/snapshots/test_sensor.ambr index eab1441399fb7..8db495adfab42 100644 --- a/tests/components/tesla_fleet/snapshots/test_sensor.ambr +++ b/tests/components/tesla_fleet/snapshots/test_sensor.ambr @@ -71,7 +71,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_battery_discharged-entry] @@ -146,7 +146,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_battery_exported-entry] @@ -1121,7 +1121,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_grid_exported_from_battery-entry] @@ -1881,7 +1881,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_load_power-entry] @@ -2178,7 +2178,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_solar_power-entry] From 80c06c689c19100ab54bbbe97a840ddd7332b4c6 Mon Sep 17 00:00:00 2001 From: MichaelMKKelly <100048727+MichaelMKKelly@users.noreply.github.com> Date: Fri, 24 Oct 2025 12:13:10 +0100 Subject: [PATCH 05/17] Move URL out of system_bridge strings.json (#155067) --- .../components/system_bridge/config_flow.py | 20 +++++++++++++++---- .../components/system_bridge/const.py | 2 ++ .../components/system_bridge/strings.json | 2 +- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/system_bridge/config_flow.py b/homeassistant/components/system_bridge/config_flow.py index 60b57b1e87fa1..fae75f4087e6b 100644 --- a/homeassistant/components/system_bridge/config_flow.py +++ b/homeassistant/components/system_bridge/config_flow.py @@ -24,7 +24,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from .const import DATA_WAIT_TIMEOUT, DOMAIN +from .const import DATA_WAIT_TIMEOUT, DOMAIN, SYNTAX_KEYS_DOCUMENTATION_URL _LOGGER = logging.getLogger(__name__) @@ -132,7 +132,11 @@ async def async_step_user( """Handle the initial step.""" if user_input is None: return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + description_placeholders={ + "syntax_keys_documentation_url": SYNTAX_KEYS_DOCUMENTATION_URL + }, ) errors, info = await _async_get_info(self.hass, user_input) @@ -144,7 +148,12 @@ async def async_step_user( return self.async_create_entry(title=info["hostname"], data=user_input) return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + description_placeholders={ + "syntax_keys_documentation_url": SYNTAX_KEYS_DOCUMENTATION_URL + }, ) async def async_step_authenticate( @@ -174,7 +183,10 @@ async def async_step_authenticate( return self.async_show_form( step_id="authenticate", data_schema=STEP_AUTHENTICATE_DATA_SCHEMA, - description_placeholders={"name": self._name}, + description_placeholders={ + "name": self._name, + "syntax_keys_documentation_url": SYNTAX_KEYS_DOCUMENTATION_URL, + }, errors=errors, ) diff --git a/homeassistant/components/system_bridge/const.py b/homeassistant/components/system_bridge/const.py index 235d7e6b9869d..72160b86daae7 100644 --- a/homeassistant/components/system_bridge/const.py +++ b/homeassistant/components/system_bridge/const.py @@ -4,6 +4,8 @@ from systembridgemodels.modules import Module +SYNTAX_KEYS_DOCUMENTATION_URL = "http://robotjs.io/docs/syntax#keys" + DOMAIN = "system_bridge" MODULES: Final[list[Module]] = [ diff --git a/homeassistant/components/system_bridge/strings.json b/homeassistant/components/system_bridge/strings.json index 0cca826684a67..7dd46aa0ecbf4 100644 --- a/homeassistant/components/system_bridge/strings.json +++ b/homeassistant/components/system_bridge/strings.json @@ -194,7 +194,7 @@ }, "key": { "name": "Key", - "description": "Key to press. List available here: http://robotjs.io/docs/syntax#keys." + "description": "Key to press. List available here: {syntax_keys_documentation_url}." } } }, From 5f008dcae5f1cd071d178040da5d9edf4b4d1894 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Fri, 24 Oct 2025 13:17:14 +0200 Subject: [PATCH 06/17] Correct serial number for Zigbee devices in ViCare integration (#155057) Co-authored-by: Erwin Douna --- homeassistant/components/vicare/entity.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/vicare/entity.py b/homeassistant/components/vicare/entity.py index 6dd986c3e2414..57143f7c15356 100644 --- a/homeassistant/components/vicare/entity.py +++ b/homeassistant/components/vicare/entity.py @@ -45,7 +45,6 @@ def __init__( self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, identifier)}, - serial_number=device_serial, name=model, manufacturer="Viessmann", model=model, @@ -60,3 +59,12 @@ def __init__( DOMAIN, f"{gateway_serial}_zigbee_{zigbee_ieee}", ) + elif ( + len(parts) == 2 + and len(zigbee_ieee := device_serial.removeprefix("zigbee-")) == 16 + ): + self._attr_device_info["serial_number"] = "-".join( + zigbee_ieee.upper()[i : i + 2] for i in range(0, 16, 2) + ) + else: + self._attr_device_info["serial_number"] = device_serial From 64493ca578e717c05c41db63a94c99577c5cf9d1 Mon Sep 17 00:00:00 2001 From: Vincent Wolsink Date: Fri, 24 Oct 2025 13:18:03 +0200 Subject: [PATCH 07/17] Return default temp range if API responds 0 in Huum. (#153871) --- homeassistant/components/huum/climate.py | 5 +-- homeassistant/components/huum/const.py | 3 ++ tests/components/huum/test_climate.py | 40 ++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/huum/climate.py b/homeassistant/components/huum/climate.py index af4e8cc3623ff..c520880e6919c 100644 --- a/homeassistant/components/huum/climate.py +++ b/homeassistant/components/huum/climate.py @@ -18,6 +18,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .const import CONFIG_DEFAULT_MAX_TEMP, CONFIG_DEFAULT_MIN_TEMP from .coordinator import HuumConfigEntry, HuumDataUpdateCoordinator from .entity import HuumBaseEntity @@ -55,12 +56,12 @@ def __init__(self, coordinator: HuumDataUpdateCoordinator) -> None: @property def min_temp(self) -> int: """Return configured minimal temperature.""" - return self.coordinator.data.sauna_config.min_temp + return self.coordinator.data.sauna_config.min_temp or CONFIG_DEFAULT_MIN_TEMP @property def max_temp(self) -> int: """Return configured maximum temperature.""" - return self.coordinator.data.sauna_config.max_temp + return self.coordinator.data.sauna_config.max_temp or CONFIG_DEFAULT_MAX_TEMP @property def hvac_mode(self) -> HVACMode: diff --git a/homeassistant/components/huum/const.py b/homeassistant/components/huum/const.py index 177c035f0415a..28609a7e92024 100644 --- a/homeassistant/components/huum/const.py +++ b/homeassistant/components/huum/const.py @@ -9,3 +9,6 @@ CONFIG_STEAMER = 1 CONFIG_LIGHT = 2 CONFIG_STEAMER_AND_LIGHT = 3 + +CONFIG_DEFAULT_MIN_TEMP = 40 +CONFIG_DEFAULT_MAX_TEMP = 110 diff --git a/tests/components/huum/test_climate.py b/tests/components/huum/test_climate.py index ca7fcf8118512..3e0a07f92325f 100644 --- a/tests/components/huum/test_climate.py +++ b/tests/components/huum/test_climate.py @@ -11,6 +11,10 @@ SERVICE_SET_TEMPERATURE, HVACMode, ) +from homeassistant.components.huum.const import ( + CONFIG_DEFAULT_MAX_TEMP, + CONFIG_DEFAULT_MIN_TEMP, +) from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -76,3 +80,39 @@ async def test_set_temperature( ) mock_huum.turn_on.assert_called_once_with(60) + + +async def test_temperature_range( + hass: HomeAssistant, + mock_huum: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the temperature range.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + # API response. + state = hass.states.get(ENTITY_ID) + assert state.attributes["min_temp"] == 40 + assert state.attributes["max_temp"] == 110 + + # Empty/unconfigured API response should return default values. + mock_huum.sauna_config.min_temp = 0 + mock_huum.sauna_config.max_temp = 0 + + await mock_config_entry.runtime_data.async_refresh() + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state.attributes["min_temp"] == CONFIG_DEFAULT_MIN_TEMP + assert state.attributes["max_temp"] == CONFIG_DEFAULT_MAX_TEMP + + # Custom configured API response. + mock_huum.sauna_config.min_temp = 50 + mock_huum.sauna_config.max_temp = 80 + + await mock_config_entry.runtime_data.async_refresh() + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state.attributes["min_temp"] == 50 + assert state.attributes["max_temp"] == 80 From 10e2c7ec95baebd908e735578112c53cd11834c4 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Fri, 24 Oct 2025 13:25:09 +0200 Subject: [PATCH 08/17] Translate Z-Wave "Socket device path" in config flow (#154931) --- homeassistant/components/zwave_js/strings.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 70ea973c3c81b..46cb9fa50734d 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -40,6 +40,7 @@ "step": { "configure_addon_user": { "data": { + "socket_path": "Socket device path", "usb_path": "[%key:common::config_flow::data::usb_path%]" }, "description": "Select your Z-Wave adapter", @@ -71,6 +72,7 @@ "s2_access_control_key": "[%key:component::zwave_js::config::step::configure_security_keys::data::s2_access_control_key%]", "s2_authenticated_key": "[%key:component::zwave_js::config::step::configure_security_keys::data::s2_authenticated_key%]", "s2_unauthenticated_key": "[%key:component::zwave_js::config::step::configure_security_keys::data::s2_unauthenticated_key%]", + "socket_path": "[%key:component::zwave_js::config::step::configure_addon_user::data::socket_path%]", "usb_path": "[%key:common::config_flow::data::usb_path%]" }, "description": "[%key:component::zwave_js::config::step::configure_addon_user::description%]", @@ -158,6 +160,7 @@ }, "choose_serial_port": { "data": { + "socket_path": "[%key:component::zwave_js::config::step::configure_addon_user::data::socket_path%]", "usb_path": "[%key:common::config_flow::data::usb_path%]" }, "title": "Select your Z-Wave device" From c72072fbfb941e8e2533b97ab878adf1dcd3dcc9 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Fri, 24 Oct 2025 12:36:05 +0100 Subject: [PATCH 09/17] Use icon translations for system bridge entities (#155090) --- .../components/system_bridge/binary_sensor.py | 2 - .../components/system_bridge/icons.json | 56 +++++++++++++++++++ .../components/system_bridge/media_player.py | 1 - .../components/system_bridge/sensor.py | 13 ----- 4 files changed, 56 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/system_bridge/binary_sensor.py b/homeassistant/components/system_bridge/binary_sensor.py index 0140499a75a5e..883c74f2589d9 100644 --- a/homeassistant/components/system_bridge/binary_sensor.py +++ b/homeassistant/components/system_bridge/binary_sensor.py @@ -39,13 +39,11 @@ def camera_in_use(data: SystemBridgeData) -> bool | None: SystemBridgeBinarySensorEntityDescription( key="camera_in_use", translation_key="camera_in_use", - icon="mdi:webcam", value_fn=camera_in_use, ), SystemBridgeBinarySensorEntityDescription( key="pending_reboot", translation_key="pending_reboot", - icon="mdi:restart", value_fn=lambda data: data.system.pending_reboot, ), SystemBridgeBinarySensorEntityDescription( diff --git a/homeassistant/components/system_bridge/icons.json b/homeassistant/components/system_bridge/icons.json index a03f77049a315..0be0a2d7c028f 100644 --- a/homeassistant/components/system_bridge/icons.json +++ b/homeassistant/components/system_bridge/icons.json @@ -1,4 +1,60 @@ { + "entity": { + "binary_sensor": { + "camera_in_use": { + "default": "mdi:webcam" + }, + "pending_reboot": { + "default": "mdi:restart" + } + }, + "media_player": { + "media": { + "default": "mdi:volume-high" + } + }, + "sensor": { + "boot_time": { + "default": "mdi:av-timer" + }, + "cpu_power_package": { + "default": "mdi:chip" + }, + "cpu_speed": { + "default": "mdi:speedometer" + }, + "displays_connected": { + "default": "mdi:monitor" + }, + "kernel": { + "default": "mdi:devices" + }, + "load": { + "default": "mdi:percent" + }, + "memory_free": { + "default": "mdi:memory" + }, + "memory_used": { + "default": "mdi:memory" + }, + "os": { + "default": "mdi:devices" + }, + "power_usage": { + "default": "mdi:power-plug" + }, + "processes": { + "default": "mdi:counter" + }, + "version": { + "default": "mdi:counter" + }, + "version_latest": { + "default": "mdi:counter" + } + } + }, "services": { "get_process_by_id": { "service": "mdi:console" diff --git a/homeassistant/components/system_bridge/media_player.py b/homeassistant/components/system_bridge/media_player.py index 6d3bbd21a05fb..2be2f06c1e776 100644 --- a/homeassistant/components/system_bridge/media_player.py +++ b/homeassistant/components/system_bridge/media_player.py @@ -57,7 +57,6 @@ MediaPlayerEntityDescription( key="media", translation_key="media", - icon="mdi:volume-high", device_class=MediaPlayerDeviceClass.RECEIVER, ) ) diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index 8ad3ede39606b..f07c96fe8caad 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -233,7 +233,6 @@ def partition_usage( key="boot_time", translation_key="boot_time", device_class=SensorDeviceClass.TIMESTAMP, - icon="mdi:av-timer", value=lambda data: datetime.fromtimestamp(data.system.boot_time, tz=UTC), ), SystemBridgeSensorEntityDescription( @@ -242,7 +241,6 @@ def partition_usage( native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=2, - icon="mdi:chip", value=lambda data: data.cpu.power, ), SystemBridgeSensorEntityDescription( @@ -252,7 +250,6 @@ def partition_usage( native_unit_of_measurement=UnitOfFrequency.GIGAHERTZ, device_class=SensorDeviceClass.FREQUENCY, suggested_display_precision=2, - icon="mdi:speedometer", value=cpu_speed, ), SystemBridgeSensorEntityDescription( @@ -278,7 +275,6 @@ def partition_usage( SystemBridgeSensorEntityDescription( key="kernel", translation_key="kernel", - icon="mdi:devices", value=lambda data: data.system.platform, ), SystemBridgeSensorEntityDescription( @@ -288,7 +284,6 @@ def partition_usage( native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, suggested_display_precision=2, - icon="mdi:memory", value=memory_free, ), SystemBridgeSensorEntityDescription( @@ -307,20 +302,17 @@ def partition_usage( native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, suggested_display_precision=2, - icon="mdi:memory", value=memory_used, ), SystemBridgeSensorEntityDescription( key="os", translation_key="os", - icon="mdi:devices", value=lambda data: f"{data.system.platform} {data.system.platform_version}", ), SystemBridgeSensorEntityDescription( key="processes_count", translation_key="processes", state_class=SensorStateClass.MEASUREMENT, - icon="mdi:counter", value=lambda data: len(data.processes), ), SystemBridgeSensorEntityDescription( @@ -329,7 +321,6 @@ def partition_usage( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, suggested_display_precision=1, - icon="mdi:percent", value=lambda data: data.cpu.usage, ), SystemBridgeSensorEntityDescription( @@ -339,19 +330,16 @@ def partition_usage( device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, suggested_display_precision=2, - icon="mdi:power-plug", value=lambda data: data.system.power_usage, ), SystemBridgeSensorEntityDescription( key="version", translation_key="version", - icon="mdi:counter", value=lambda data: data.system.version, ), SystemBridgeSensorEntityDescription( key="version_latest", translation_key="version_latest", - icon="mdi:counter", value=lambda data: data.system.version_latest, ), ) @@ -429,7 +417,6 @@ async def async_setup_entry( key="displays_connected", translation_key="displays_connected", state_class=SensorStateClass.MEASUREMENT, - icon="mdi:monitor", value=lambda data: len(data.displays) if data.displays else None, ), entry.data[CONF_PORT], From 3c098db35ef3890c9de6125223bf698d6582730a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Fri, 24 Oct 2025 15:20:21 +0200 Subject: [PATCH 10/17] Update aioairzone to v1.0.2 (#155088) --- .../components/airzone/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../airzone/snapshots/test_diagnostics.ambr | 61 +++++++++---------- 4 files changed, 33 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index 6e4b0b50c4c02..6134f6c1a5d89 100644 --- a/homeassistant/components/airzone/manifest.json +++ b/homeassistant/components/airzone/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone", "iot_class": "local_polling", "loggers": ["aioairzone"], - "requirements": ["aioairzone==1.0.1"] + "requirements": ["aioairzone==1.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 73e68a57575f5..0c28f2b9abfab 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -188,7 +188,7 @@ aioairq==0.4.7 aioairzone-cloud==0.7.2 # homeassistant.components.airzone -aioairzone==1.0.1 +aioairzone==1.0.2 # homeassistant.components.alexa_devices aioamazondevices==6.4.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2d4bd4f68f973..e1339f46f3b9b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -176,7 +176,7 @@ aioairq==0.4.7 aioairzone-cloud==0.7.2 # homeassistant.components.airzone -aioairzone==1.0.1 +aioairzone==1.0.2 # homeassistant.components.alexa_devices aioamazondevices==6.4.6 diff --git a/tests/components/airzone/snapshots/test_diagnostics.ambr b/tests/components/airzone/snapshots/test_diagnostics.ambr index 0895073e0aaf3..19c05f52d8dcd 100644 --- a/tests/components/airzone/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone/snapshots/test_diagnostics.ambr @@ -328,65 +328,64 @@ 'firmware': '3.31', 'full-name': 'Airzone [1] System', 'id': 1, - 'master-system-zone': '1:1', - 'master-zone': 1, - 'mode': 3, - 'model': 'C6', - 'modes': list([ + 'masters': list([ 1, - 4, + ]), + 'masters-slaves': dict({ + '1': list([ + 2, + 3, + 4, + 5, + ]), + }), + 'model': 'C6', + 'problems': False, + 'q-adapt': 0, + 'slaves': list([ 2, 3, + 4, 5, ]), - 'problems': False, - 'q-adapt': 0, }), '2': dict({ 'available': True, 'full-name': 'Airzone [2] System', 'id': 2, - 'master-system-zone': '2:1', - 'master-zone': 1, - 'mode': 7, - 'modes': list([ - 7, + 'masters': list([ 1, ]), + 'masters-slaves': dict({ + '1': list([ + ]), + }), 'problems': False, }), '3': dict({ 'available': True, 'full-name': 'Airzone [3] System', 'id': 3, - 'master-system-zone': '3:1', - 'master-zone': 1, - 'mode': 7, - 'modes': list([ - 4, - 2, - 3, - 5, - 7, + 'masters': list([ 1, ]), + 'masters-slaves': dict({ + '1': list([ + ]), + }), 'problems': False, }), '4': dict({ 'available': True, 'full-name': 'Airzone [4] System', 'id': 4, - 'master-system-zone': '4:1', - 'master-zone': 1, - 'mode': 6, - 'modes': list([ + 'masters': list([ 1, - 2, - 3, - 4, - 5, - 6, ]), + 'masters-slaves': dict({ + '1': list([ + ]), + }), 'problems': False, }), }), From 43445f5945d4b25bdefbc7a5743e585c8d220dbe Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 24 Oct 2025 15:37:40 +0200 Subject: [PATCH 11/17] Add tests for Yardian switch (#155089) --- .../yardian/snapshots/test_switch.ambr | 97 +++++++++++++++++++ tests/components/yardian/test_switch.py | 73 ++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 tests/components/yardian/snapshots/test_switch.ambr create mode 100644 tests/components/yardian/test_switch.py diff --git a/tests/components/yardian/snapshots/test_switch.ambr b/tests/components/yardian/snapshots/test_switch.ambr new file mode 100644 index 0000000000000..585dcc6cfeffa --- /dev/null +++ b/tests/components/yardian/snapshots/test_switch.ambr @@ -0,0 +1,97 @@ +# serializer version: 1 +# name: test_all_entities[switch.yardian_smart_sprinkler_zone_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.yardian_smart_sprinkler_zone_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Zone 1', + 'platform': 'yardian', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch', + 'unique_id': 'yid123-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.yardian_smart_sprinkler_zone_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Yardian Smart Sprinkler Zone 1', + }), + 'context': , + 'entity_id': 'switch.yardian_smart_sprinkler_zone_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.yardian_smart_sprinkler_zone_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.yardian_smart_sprinkler_zone_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Zone 2', + 'platform': 'yardian', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch', + 'unique_id': 'yid123-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.yardian_smart_sprinkler_zone_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Yardian Smart Sprinkler Zone 2', + }), + 'context': , + 'entity_id': 'switch.yardian_smart_sprinkler_zone_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/yardian/test_switch.py b/tests/components/yardian/test_switch.py new file mode 100644 index 0000000000000..eb648eae0073d --- /dev/null +++ b/tests/components/yardian/test_switch.py @@ -0,0 +1,73 @@ +"""Validate Yardian switch behavior.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_yardian_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.yardian.PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_turn_on_switch( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_yardian_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test turning on a switch.""" + await setup_integration(hass, mock_config_entry) + + entity_id = "switch.yardian_smart_sprinkler_zone_1" + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_yardian_client.start_irrigation.assert_called_once_with(0, 6) + + +async def test_turn_off_switch( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_yardian_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test turning off a switch.""" + await setup_integration(hass, mock_config_entry) + + entity_id = "switch.yardian_smart_sprinkler_zone_1" + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_yardian_client.stop_irrigation.assert_called_once() From a571271f6aca5be7a15bf0cd068864fc1f34ff92 Mon Sep 17 00:00:00 2001 From: Lorenzo Gasparini <112936414+Lorenzo-Gasparini@users.noreply.github.com> Date: Fri, 24 Oct 2025 15:45:00 +0200 Subject: [PATCH 12/17] Add Fing integration (#126058) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Norbert Rittel Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 2 + homeassistant/components/fing/__init__.py | 42 +++++ homeassistant/components/fing/config_flow.py | 114 +++++++++++++ homeassistant/components/fing/const.py | 4 + homeassistant/components/fing/coordinator.py | 85 ++++++++++ .../components/fing/device_tracker.py | 127 ++++++++++++++ homeassistant/components/fing/manifest.json | 10 ++ .../components/fing/quality_scale.yaml | 72 ++++++++ homeassistant/components/fing/strings.json | 31 ++++ homeassistant/components/fing/utils.py | 85 ++++++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/fing/__init__.py | 17 ++ tests/components/fing/conftest.py | 55 ++++++ .../fing/fixtures/agent_info_response.xml | 45 +++++ .../fixtures/device_resp_device_added.json | 45 +++++ .../fixtures/device_resp_device_deleted.json | 24 +++ .../fixtures/device_resp_device_modified.json | 35 ++++ .../fing/fixtures/device_resp_new_API.json | 35 ++++ .../fing/fixtures/device_resp_old_API.json | 34 ++++ .../fing/snapshots/test_device_tracker.ambr | 157 ++++++++++++++++++ tests/components/fing/test_config_flow.py | 154 +++++++++++++++++ tests/components/fing/test_device_tracker.py | 127 ++++++++++++++ tests/components/fing/test_init.py | 47 ++++++ 26 files changed, 1360 insertions(+) create mode 100644 homeassistant/components/fing/__init__.py create mode 100644 homeassistant/components/fing/config_flow.py create mode 100644 homeassistant/components/fing/const.py create mode 100644 homeassistant/components/fing/coordinator.py create mode 100644 homeassistant/components/fing/device_tracker.py create mode 100644 homeassistant/components/fing/manifest.json create mode 100644 homeassistant/components/fing/quality_scale.yaml create mode 100644 homeassistant/components/fing/strings.json create mode 100644 homeassistant/components/fing/utils.py create mode 100644 tests/components/fing/__init__.py create mode 100644 tests/components/fing/conftest.py create mode 100644 tests/components/fing/fixtures/agent_info_response.xml create mode 100644 tests/components/fing/fixtures/device_resp_device_added.json create mode 100644 tests/components/fing/fixtures/device_resp_device_deleted.json create mode 100644 tests/components/fing/fixtures/device_resp_device_modified.json create mode 100644 tests/components/fing/fixtures/device_resp_new_API.json create mode 100644 tests/components/fing/fixtures/device_resp_old_API.json create mode 100644 tests/components/fing/snapshots/test_device_tracker.ambr create mode 100644 tests/components/fing/test_config_flow.py create mode 100644 tests/components/fing/test_device_tracker.py create mode 100644 tests/components/fing/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index 9cfa84d0e9368..696b7a0f2f024 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -494,6 +494,8 @@ build.json @home-assistant/supervisor /tests/components/filesize/ @gjohansson-ST /homeassistant/components/filter/ @dgomes /tests/components/filter/ @dgomes +/homeassistant/components/fing/ @Lorenzo-Gasparini +/tests/components/fing/ @Lorenzo-Gasparini /homeassistant/components/firefly_iii/ @erwindouna /tests/components/firefly_iii/ @erwindouna /homeassistant/components/fireservicerota/ @cyberjunky diff --git a/homeassistant/components/fing/__init__.py b/homeassistant/components/fing/__init__.py new file mode 100644 index 0000000000000..699bc447cf008 --- /dev/null +++ b/homeassistant/components/fing/__init__.py @@ -0,0 +1,42 @@ +"""The Fing integration.""" + +from __future__ import annotations + +import logging + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError + +from .coordinator import FingConfigEntry, FingDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [Platform.DEVICE_TRACKER] + + +async def async_setup_entry(hass: HomeAssistant, config_entry: FingConfigEntry) -> bool: + """Set up the Fing component.""" + + coordinator = FingDataUpdateCoordinator(hass, config_entry) + await coordinator.async_config_entry_first_refresh() + + if coordinator.data.network_id is None: + _LOGGER.warning( + "Skip setting up Fing integration; Received an empty NetworkId from the request - Check if the API version is the latest" + ) + raise ConfigEntryError( + "The Agent's API version is outdated. Please update the agent to the latest version." + ) + + config_entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, config_entry: FingConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/fing/config_flow.py b/homeassistant/components/fing/config_flow.py new file mode 100644 index 0000000000000..0c99f7e34dbb8 --- /dev/null +++ b/homeassistant/components/fing/config_flow.py @@ -0,0 +1,114 @@ +"""Config flow file.""" + +from contextlib import suppress +import logging +from typing import Any + +from fing_agent_api import FingAgent +import httpx +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_KEY, CONF_IP_ADDRESS, CONF_PORT + +from .const import DOMAIN, UPNP_AVAILABLE + +_LOGGER = logging.getLogger(__name__) + + +class FingConfigFlow(ConfigFlow, domain=DOMAIN): + """Fing config flow.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Set up user step.""" + errors: dict[str, str] = {} + description_placeholders: dict[str, str] = {} + + if user_input is not None: + devices_response = None + agent_info_response = None + + self._async_abort_entries_match( + {CONF_IP_ADDRESS: user_input[CONF_IP_ADDRESS]} + ) + + fing_api = FingAgent( + ip=user_input[CONF_IP_ADDRESS], + port=int(user_input[CONF_PORT]), + key=user_input[CONF_API_KEY], + ) + + try: + devices_response = await fing_api.get_devices() + + with suppress(httpx.ConnectError): + # The suppression is needed because the get_agent_info method isn't available for desktop agents + agent_info_response = await fing_api.get_agent_info() + + except httpx.NetworkError as _: + errors["base"] = "cannot_connect" + except httpx.TimeoutException as _: + errors["base"] = "timeout_connect" + except httpx.HTTPStatusError as exception: + description_placeholders["message"] = ( + f"{exception.response.status_code} - {exception.response.reason_phrase}" + ) + if exception.response.status_code == 401: + errors["base"] = "invalid_api_key" + else: + errors["base"] = "http_status_error" + except httpx.InvalidURL as _: + errors["base"] = "url_error" + except ( + httpx.HTTPError, + httpx.CookieConflict, + httpx.StreamError, + ) as ex: + _LOGGER.error("Unexpected exception: %s", ex) + errors["base"] = "unknown" + else: + if ( + devices_response.network_id is not None + and len(devices_response.network_id) > 0 + ): + agent_name = user_input.get(CONF_IP_ADDRESS) + upnp_available = False + if agent_info_response is not None: + upnp_available = True + agent_name = agent_info_response.agent_id + await self.async_set_unique_id(agent_info_response.agent_id) + self._abort_if_unique_id_configured() + + data = { + CONF_IP_ADDRESS: user_input[CONF_IP_ADDRESS], + CONF_PORT: user_input[CONF_PORT], + CONF_API_KEY: user_input[CONF_API_KEY], + UPNP_AVAILABLE: upnp_available, + } + + return self.async_create_entry( + title=f"Fing Agent {agent_name}", + data=data, + ) + + return self.async_abort(reason="api_version_error") + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_IP_ADDRESS): str, + vol.Required(CONF_PORT, default="49090"): str, + vol.Required(CONF_API_KEY): str, + } + ), + user_input, + ), + errors=errors, + description_placeholders=description_placeholders, + ) diff --git a/homeassistant/components/fing/const.py b/homeassistant/components/fing/const.py new file mode 100644 index 0000000000000..65b5d27e55804 --- /dev/null +++ b/homeassistant/components/fing/const.py @@ -0,0 +1,4 @@ +"""Const for the Fing integration.""" + +DOMAIN = "fing" +UPNP_AVAILABLE = "upnp_available" diff --git a/homeassistant/components/fing/coordinator.py b/homeassistant/components/fing/coordinator.py new file mode 100644 index 0000000000000..84d44ce5d7350 --- /dev/null +++ b/homeassistant/components/fing/coordinator.py @@ -0,0 +1,85 @@ +"""DataUpdateCoordinator for Fing integration.""" + +from dataclasses import dataclass, field +from datetime import timedelta +import logging + +from fing_agent_api import FingAgent +from fing_agent_api.models import AgentInfoResponse, Device +import httpx + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_IP_ADDRESS, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, UPNP_AVAILABLE + +_LOGGER = logging.getLogger(__name__) + +type FingConfigEntry = ConfigEntry[FingDataUpdateCoordinator] + + +@dataclass +class FingDataObject: + """Fing Data Object.""" + + network_id: str | None = None + agent_info: AgentInfoResponse | None = None + devices: dict[str, Device] = field(default_factory=dict) + + +class FingDataUpdateCoordinator(DataUpdateCoordinator[FingDataObject]): + """Class to manage fetching data from Fing Agent.""" + + def __init__(self, hass: HomeAssistant, config_entry: FingConfigEntry) -> None: + """Initialize global Fing updater.""" + self._fing = FingAgent( + ip=config_entry.data[CONF_IP_ADDRESS], + port=int(config_entry.data[CONF_PORT]), + key=config_entry.data[CONF_API_KEY], + ) + self._upnp_available = config_entry.data[UPNP_AVAILABLE] + update_interval = timedelta(seconds=30) + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=update_interval, + config_entry=config_entry, + ) + + async def _async_update_data(self) -> FingDataObject: + """Fetch data from Fing Agent.""" + device_response = None + agent_info_response = None + try: + device_response = await self._fing.get_devices() + + if self._upnp_available: + agent_info_response = await self._fing.get_agent_info() + + except httpx.NetworkError as err: + raise UpdateFailed("Failed to connect") from err + except httpx.TimeoutException as err: + raise UpdateFailed("Timeout establishing connection") from err + except httpx.HTTPStatusError as err: + if err.response.status_code == 401: + raise UpdateFailed("Invalid API key") from err + raise UpdateFailed( + f"Http request failed -> {err.response.status_code} - {err.response.reason_phrase}" + ) from err + except httpx.InvalidURL as err: + raise UpdateFailed("Invalid hostname or IP address") from err + except ( + httpx.HTTPError, + httpx.CookieConflict, + httpx.StreamError, + ) as err: + raise UpdateFailed("Unexpected error from HTTP request") from err + else: + return FingDataObject( + device_response.network_id, + agent_info_response, + {device.mac: device for device in device_response.devices}, + ) diff --git a/homeassistant/components/fing/device_tracker.py b/homeassistant/components/fing/device_tracker.py new file mode 100644 index 0000000000000..c87fd123a8c58 --- /dev/null +++ b/homeassistant/components/fing/device_tracker.py @@ -0,0 +1,127 @@ +"""Platform for Device tracker integration.""" + +from fing_agent_api.models import Device + +from homeassistant.components.device_tracker import ScannerEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import FingConfigEntry +from .coordinator import FingDataUpdateCoordinator +from .utils import get_icon_from_type + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: FingConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add sensors for passed config_entry in HA.""" + coordinator = config_entry.runtime_data + entity_registry = er.async_get(hass) + tracked_devices: set[str] = set() + + @callback + def add_entities() -> None: + latest_devices = set(coordinator.data.devices.keys()) + + devices_to_remove = tracked_devices - set(latest_devices) + devices_to_add = set(latest_devices) - tracked_devices + + entities_to_remove = [] + for entity_entry in entity_registry.entities.values(): + if entity_entry.config_entry_id != config_entry.entry_id: + continue + try: + _, mac = entity_entry.unique_id.rsplit("-", 1) + if mac in devices_to_remove: + entities_to_remove.append(entity_entry.entity_id) + except ValueError: + continue + + for entity_id in entities_to_remove: + entity_registry.async_remove(entity_id) + + entities_to_add = [] + for mac_addr in devices_to_add: + device = coordinator.data.devices[mac_addr] + entities_to_add.append(FingTrackedDevice(coordinator, device)) + + tracked_devices.clear() + tracked_devices.update(latest_devices) + async_add_entities(entities_to_add) + + add_entities() + config_entry.async_on_unload(coordinator.async_add_listener(add_entities)) + + +class FingTrackedDevice(CoordinatorEntity[FingDataUpdateCoordinator], ScannerEntity): + """Represent a tracked device.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: FingDataUpdateCoordinator, device: Device) -> None: + """Set up FingDevice entity.""" + super().__init__(coordinator) + + self._device = device + agent_id = coordinator.data.network_id + if coordinator.data.agent_info is not None: + agent_id = coordinator.data.agent_info.agent_id + + self._attr_mac_address = self._device.mac + self._attr_unique_id = f"{agent_id}-{self._attr_mac_address}" + self._attr_name = self._device.name + self._attr_icon = get_icon_from_type(self._device.type) + + @property + def is_connected(self) -> bool: + """Return true if the device is connected to the network.""" + return self._device.active + + @property + def ip_address(self) -> str | None: + """Return the primary ip address of the device.""" + return self._device.ip[0] if self._device.ip else None + + @property + def entity_registry_enabled_default(self) -> bool: + """Enable entity by default.""" + return True + + @property + def unique_id(self) -> str | None: + """Return the unique ID of the entity.""" + return self._attr_unique_id + + def check_for_updates(self, new_device: Device) -> bool: + """Return true if the device has updates.""" + new_device_ip = new_device.ip[0] if new_device.ip else None + current_device_ip = self._device.ip[0] if self._device.ip else None + + return ( + current_device_ip != new_device_ip + or self._device.active != new_device.active + or self._device.type != new_device.type + or self._attr_name != new_device.name + or self._attr_icon != get_icon_from_type(new_device.type) + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + updated_device_data = self.coordinator.data.devices.get(self._device.mac) + if updated_device_data is not None and self.check_for_updates( + updated_device_data + ): + self._device = updated_device_data + self._attr_name = updated_device_data.name + self._attr_icon = get_icon_from_type(updated_device_data.type) + er.async_get(self.hass).async_update_entity( + entity_id=self.entity_id, + original_name=self._attr_name, + original_icon=self._attr_icon, + ) + self.async_write_ha_state() diff --git a/homeassistant/components/fing/manifest.json b/homeassistant/components/fing/manifest.json new file mode 100644 index 0000000000000..a517b12d2324c --- /dev/null +++ b/homeassistant/components/fing/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "fing", + "name": "Fing", + "codeowners": ["@Lorenzo-Gasparini"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/fing", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["fing_agent_api==1.0.3"] +} diff --git a/homeassistant/components/fing/quality_scale.yaml b/homeassistant/components/fing/quality_scale.yaml new file mode 100644 index 0000000000000..273190261d757 --- /dev/null +++ b/homeassistant/components/fing/quality_scale.yaml @@ -0,0 +1,72 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: The integration has no actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: exempt + comment: There are no actions in Fing integration. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Fing integration entities do not use events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: The integration has no actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: The integration has no options flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery: todo + discovery-update-info: todo + docs-data-update: done + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: todo + dynamic-devices: done + entity-category: todo + entity-device-class: + status: exempt + comment: The integration creates only device tracker entities + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: done + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/fing/strings.json b/homeassistant/components/fing/strings.json new file mode 100644 index 0000000000000..347deb1fbdbcf --- /dev/null +++ b/homeassistant/components/fing/strings.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "title": "Set up Fing agent", + "data": { + "ip_address": "[%key:common::config_flow::data::ip%]", + "port": "[%key:common::config_flow::data::port%]", + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "ip_address": "IP address of the Fing agent.", + "port": "Port number of the Fing API.", + "api_key": "API key used to authenticate with the Fing API." + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "url_error": "[%key:common::config_flow::error::invalid_host%]", + "http_status_error": "HTTP request failed: {message}" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "api_version_error": "Your agent is using an outdated API version. The required 'network_id' parameter is missing. Please update to the latest API version." + } + } +} diff --git a/homeassistant/components/fing/utils.py b/homeassistant/components/fing/utils.py new file mode 100644 index 0000000000000..3f59e01fd23d0 --- /dev/null +++ b/homeassistant/components/fing/utils.py @@ -0,0 +1,85 @@ +"""Utils functions.""" + +from enum import Enum + + +class DeviceType(Enum): + """Device types enum.""" + + GENERIC = "mdi:lan-connect" + MOBILE = PHONE = "mdi:cellphone" + TABLET = IPOD = EREADER = "mdi:tablet" + WATCH = WEARABLE = "mdi:watch" + CAR = AUTOMOTIVE = "mdi:car-back" + MEDIA_PLAYER = "mdi:volume-high" + TELEVISION = "mdi:television" + GAME_CONSOLE = "mdi:nintendo-game-boy" + STREAMING_DONGLE = "mdi:cast" + LOUDSPEAKER = SOUND_SYSTEM = STB = SATELLITE = MUSIC = "mdi:speaker" + DISC_PLAYER = "mdi:disk-player" + REMOTE_CONTROL = "mdi:remote-tv" + RADIO = "mdi:radio" + PHOTO_CAMERA = PHOTOS = "mdi:camera" + MICROPHONE = VOICE_CONTROL = "mdi:microphone" + PROJECTOR = "mdi:projector" + COMPUTER = DESKTOP = "mdi:desktop-tower" + LAPTOP = "mdi:laptop" + PRINTER = "mdi:printer" + SCANNER = "mdi:scanner" + POS = "mdi:printer-pos" + CLOCK = "mdi:clock" + BARCODE = "mdi:barcode" + SURVEILLANCE_CAMERA = BABY_MONITOR = PET_MONITOR = "mdi:cctv" + POE_PLUG = HEALTH_MONITOR = SMART_HOME = SMART_METER = APPLIANCE = SLEEP = ( + "mdi:home-automation" + ) + SMART_PLUG = "mdi:power-plug" + LIGHT = "mdi:lightbulb" + THERMOSTAT = HEATING = "mdi:home-thermometer" + POWER_SYSTEM = ENERGY = "mdi:lightning-bolt" + SOLAR_PANEL = "mdi:solar-power" + WASHER = "mdi:washing-machine" + FRIDGE = "mdi:fridge" + CLEANER = "mdi:vacuum" + GARAGE = "mdi:garage" + SPRINKLER = "mdi:sprinkler" + BELL = "mdi:doorbell" + KEY_LOCK = "mdi:lock-smart" + CONTROL_PANEL = SMART_CONTROLLER = "mdi:alarm-panel" + SCALE = "mdi:scale-bathroom" + TOY = "mdi:teddy-bear" + ROBOT = "mdi:robot" + WEATHER = "mdi:weather-cloudy" + ALARM = "mdi:alarm-light" + MOTION_DETECTOR = "mdi:motion-sensor" + SMOKE = HUMIDITY = SENSOR = DOMOTZ_BOX = FINGBOX = "mdi:smoke-detector" + ROUTER = MODEM = GATEWAY = FIREWALL = VPN = SMALL_CELL = "mdi:router-network" + WIFI = WIFI_EXTENDER = "mdi:wifi" + NAS_STORAGE = "mdi:nas" + SWITCH = "mdi:switch" + USB = "mdi:usb" + CLOUD = "mdi:cloud" + BATTERY = "mdi:battery" + NETWORK_APPLIANCE = "mdi:network" + VIRTUAL_MACHINE = MAIL_SERVER = FILE_SERVER = PROXY_SERVER = WEB_SERVER = ( + DOMAIN_SERVER + ) = COMMUNICATION = "mdi:monitor" + SERVER = "mdi:server" + TERMINAL = "mdi:console" + DATABASE = "mdi:database" + RASPBERRY = ARDUINO = "mdi:raspberry-pi" + PROCESSOR = CIRCUIT_CARD = RFID = "mdi:chip" + INDUSTRIAL = "mdi:factory" + MEDICAL = "mdi:medical-bag" + VOIP = CONFERENCING = "mdi:phone-voip" + FITNESS = "mdi:dumbbell" + POOL = "mdi:pool" + SECURITY_SYSTEM = "mdi:security" + + +def get_icon_from_type(type: str) -> str: + """Return the right icon based on the type.""" + try: + return DeviceType[type].value + except (ValueError, KeyError): + return "mdi:lan-connect" diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 30799f93a9394..f3d23183f5573 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -202,6 +202,7 @@ "fibaro", "file", "filesize", + "fing", "firefly_iii", "fireservicerota", "fitbit", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index ee3c036109adc..7b933a735bf7f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1984,6 +1984,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "fing": { + "name": "Fing", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "fints": { "name": "FinTS", "integration_type": "service", diff --git a/requirements_all.txt b/requirements_all.txt index 0c28f2b9abfab..18b23b89933ca 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -955,6 +955,9 @@ feedparser==6.0.12 # homeassistant.components.file file-read-backwards==2.0.0 +# homeassistant.components.fing +fing_agent_api==1.0.3 + # homeassistant.components.fints fints==3.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e1339f46f3b9b..cc206d4ed7390 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -834,6 +834,9 @@ feedparser==6.0.12 # homeassistant.components.file file-read-backwards==2.0.0 +# homeassistant.components.fing +fing_agent_api==1.0.3 + # homeassistant.components.fints fints==3.1.0 diff --git a/tests/components/fing/__init__.py b/tests/components/fing/__init__.py new file mode 100644 index 0000000000000..e3ef8294d3192 --- /dev/null +++ b/tests/components/fing/__init__.py @@ -0,0 +1,17 @@ +"""Tests for the Fing integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def init_integration( + hass: HomeAssistant, config_entry: MockConfigEntry, mocked_fing_agent +) -> MockConfigEntry: + """Set up Fing integration for testing.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry diff --git a/tests/components/fing/conftest.py b/tests/components/fing/conftest.py new file mode 100644 index 0000000000000..6b48827152a42 --- /dev/null +++ b/tests/components/fing/conftest.py @@ -0,0 +1,55 @@ +"""Fixtures for Fing testing.""" + +import logging +from unittest.mock import MagicMock, patch + +from fing_agent_api.models import AgentInfoResponse, DeviceResponse +import pytest + +from homeassistant.components.fing.const import DOMAIN, UPNP_AVAILABLE +from homeassistant.const import CONF_API_KEY, CONF_IP_ADDRESS, CONF_PORT + +from tests.common import Generator, load_fixture, load_json_object_fixture +from tests.conftest import MockConfigEntry + +_LOGGER = logging.getLogger(__name__) + + +@pytest.fixture +def mock_config_entry(api_type: str) -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: "192.168.1.1", + CONF_PORT: "49090", + CONF_API_KEY: "test_key", + UPNP_AVAILABLE: api_type == "new", + }, + unique_id="test_agent_id", + ) + + +@pytest.fixture +def api_type() -> str: + """API type to load.""" + return "new" + + +@pytest.fixture +def mocked_fing_agent(api_type: str) -> Generator[MagicMock]: + """Mock a FingAgent instance depending on api_type.""" + with ( + patch( + "homeassistant.components.fing.coordinator.FingAgent", autospec=True + ) as mock_agent, + patch("homeassistant.components.fing.config_flow.FingAgent", new=mock_agent), + ): + instance = mock_agent.return_value + instance.get_devices.return_value = DeviceResponse( + load_json_object_fixture(f"device_resp_{api_type}_API.json", DOMAIN) + ) + instance.get_agent_info.return_value = AgentInfoResponse( + load_fixture("agent_info_response.xml", DOMAIN) + ) + yield instance diff --git a/tests/components/fing/fixtures/agent_info_response.xml b/tests/components/fing/fixtures/agent_info_response.xml new file mode 100644 index 0000000000000..acf398b73bc3d --- /dev/null +++ b/tests/components/fing/fixtures/agent_info_response.xml @@ -0,0 +1,45 @@ + + + +1 +0 + +http://192.168.0.10:44444 + +urn:domotz:fingbox:0.0.0 +Fingbox +Domotz +http://www.fing.io +Fingbox +Fingbox + +http://www.fing.io +123 +uuid:UNIQUE_ID + + +urn:domotz:device:fingbox:active:1 + + +urn:domotz:device:fingbox:hwp:fingbox-v2018 + + +urn:domotz:device:fingbox:mac:0000000000XX + + +urn:domotz:device:fingbox:ver:0.0.0 + + +urn:domotz:device:fingbox:netsign:000000000000 + + +urn:domotz:device:fingbox:koss:XX + + +urn:domotz:device:fingbox:recogport:80 + + +/ + + + diff --git a/tests/components/fing/fixtures/device_resp_device_added.json b/tests/components/fing/fixtures/device_resp_device_added.json new file mode 100644 index 0000000000000..69633d497250c --- /dev/null +++ b/tests/components/fing/fixtures/device_resp_device_added.json @@ -0,0 +1,45 @@ +{ + "networkId": "TEST", + "devices": [ + { + "mac": "00:00:00:00:00:01", + "ip": ["192.168.50.1"], + "state": "UP", + "name": "FreeBSD router", + "type": "FIREWALL", + "make": "OPNsense", + "first_seen": "2024-08-26T13:28:57.927Z" + }, + { + "mac": "00:00:00:00:00:02", + "ip": ["192.168.50.2"], + "state": "UP", + "name": "Samsung The Frame 55", + "type": "TELEVISION", + "make": "Samsung", + "model": "The Frame 55", + "first_seen": "2024-08-26T14:03:31.497Z", + "last_changed": "2024-08-26T14:52:08.559Z" + }, + { + "mac": "00:00:00:00:00:03", + "ip": ["192.168.50.3"], + "state": "UP", + "name": "PC_HOME", + "type": "COMPUTER", + "make": "Dell", + "first_seen": "2024-08-26T13:28:57.927Z", + "last_changed": "2024-09-02T11:15:15.456Z" + }, + { + "mac": "54:44:A3:5F:D0:38", + "ip": ["192.168.50.10"], + "state": "UP", + "name": "Samsung", + "type": "LOUDSPEAKER", + "make": "Samsung", + "model": "HW-S800B", + "first_seen": "2024-08-26T13:28:57.927Z" + } + ] +} diff --git a/tests/components/fing/fixtures/device_resp_device_deleted.json b/tests/components/fing/fixtures/device_resp_device_deleted.json new file mode 100644 index 0000000000000..e630baa426ccd --- /dev/null +++ b/tests/components/fing/fixtures/device_resp_device_deleted.json @@ -0,0 +1,24 @@ +{ + "networkId": "TEST", + "devices": [ + { + "mac": "00:00:00:00:00:01", + "ip": ["192.168.50.1"], + "state": "UP", + "name": "FreeBSD router", + "type": "FIREWALL", + "make": "OPNsense", + "first_seen": "2024-08-26T13:28:57.927Z" + }, + { + "mac": "54:44:A3:5F:D0:38", + "ip": ["192.168.50.10"], + "state": "UP", + "name": "Samsung", + "type": "LOUDSPEAKER", + "make": "Samsung", + "model": "HW-S800B", + "first_seen": "2024-08-26T13:28:57.927Z" + } + ] +} diff --git a/tests/components/fing/fixtures/device_resp_device_modified.json b/tests/components/fing/fixtures/device_resp_device_modified.json new file mode 100644 index 0000000000000..f57cdcf8da473 --- /dev/null +++ b/tests/components/fing/fixtures/device_resp_device_modified.json @@ -0,0 +1,35 @@ +{ + "networkId": "TEST", + "devices": [ + { + "mac": "00:00:00:00:00:01", + "ip": ["192.168.50.1"], + "state": "UP", + "name": "FreeBSD router", + "type": "FIREWALL", + "make": "OPNsense", + "first_seen": "2024-08-26T13:28:57.927Z" + }, + { + "mac": "00:00:00:00:00:02", + "ip": ["192.168.50.2"], + "state": "UP", + "name": "Samsung The Frame 55", + "type": "TELEVISION", + "make": "Samsung", + "model": "The Frame 55", + "first_seen": "2024-08-26T14:03:31.497Z", + "last_changed": "2024-08-26T14:52:08.559Z" + }, + { + "mac": "00:00:00:00:00:03", + "ip": ["192.168.50.3"], + "state": "UP", + "name": "NEW_PC_HOME", + "type": "TABLET", + "make": "Dell", + "first_seen": "2024-08-26T13:28:57.927Z", + "last_changed": "2024-09-02T11:15:15.456Z" + } + ] +} diff --git a/tests/components/fing/fixtures/device_resp_new_API.json b/tests/components/fing/fixtures/device_resp_new_API.json new file mode 100644 index 0000000000000..d4d1120c027b2 --- /dev/null +++ b/tests/components/fing/fixtures/device_resp_new_API.json @@ -0,0 +1,35 @@ +{ + "networkId": "TEST", + "devices": [ + { + "mac": "00:00:00:00:00:01", + "ip": ["192.168.50.1"], + "state": "UP", + "name": "FreeBSD router", + "type": "FIREWALL", + "make": "OPNsense", + "first_seen": "2024-08-26T13:28:57.927Z" + }, + { + "mac": "00:00:00:00:00:02", + "ip": ["192.168.50.2"], + "state": "UP", + "name": "Samsung The Frame 55", + "type": "TELEVISION", + "make": "Samsung", + "model": "The Frame 55", + "first_seen": "2024-08-26T14:03:31.497Z", + "last_changed": "2024-08-26T14:52:08.559Z" + }, + { + "mac": "00:00:00:00:00:03", + "ip": ["192.168.50.3"], + "state": "UP", + "name": "PC_HOME", + "type": "COMPUTER", + "make": "Dell", + "first_seen": "2024-08-26T13:28:57.927Z", + "last_changed": "2024-09-02T11:15:15.456Z" + } + ] +} diff --git a/tests/components/fing/fixtures/device_resp_old_API.json b/tests/components/fing/fixtures/device_resp_old_API.json new file mode 100644 index 0000000000000..39399b4a2bbc7 --- /dev/null +++ b/tests/components/fing/fixtures/device_resp_old_API.json @@ -0,0 +1,34 @@ +{ + "devices": [ + { + "mac": "00:00:00:00:00:01", + "ip": ["192.168.50.1"], + "state": "UP", + "name": "FreeBSD router", + "type": "FIREWALL", + "make": "OPNsense", + "first_seen": "2024-08-26T13:28:57.927Z" + }, + { + "mac": "00:00:00:00:00:02", + "ip": ["192.168.50.2"], + "state": "UP", + "name": "Samsung The Frame 55", + "type": "TELEVISION", + "make": "Samsung", + "model": "The Frame 55", + "first_seen": "2024-08-26T14:03:31.497Z", + "last_changed": "2024-08-26T14:52:08.559Z" + }, + { + "mac": "00:00:00:00:00:03", + "ip": ["192.168.50.3"], + "state": "UP", + "name": "PC_HOME", + "type": "COMPUTER", + "make": "Dell", + "first_seen": "2024-08-26T13:28:57.927Z", + "last_changed": "2024-09-02T11:15:15.456Z" + } + ] +} diff --git a/tests/components/fing/snapshots/test_device_tracker.ambr b/tests/components/fing/snapshots/test_device_tracker.ambr new file mode 100644 index 0000000000000..cf56171c26ea6 --- /dev/null +++ b/tests/components/fing/snapshots/test_device_tracker.ambr @@ -0,0 +1,157 @@ +# serializer version: 1 +# name: test_all_entities[device_tracker.freebsd_router-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.freebsd_router', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:router-network', + 'original_name': 'FreeBSD router', + 'platform': 'fing', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '0000000000XX-00:00:00:00:00:01', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[device_tracker.freebsd_router-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'FreeBSD router', + 'icon': 'mdi:router-network', + 'ip': '192.168.50.1', + 'mac': '00:00:00:00:00:01', + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.freebsd_router', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'home', + }) +# --- +# name: test_all_entities[device_tracker.pc_home-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.pc_home', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:desktop-tower', + 'original_name': 'PC_HOME', + 'platform': 'fing', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '0000000000XX-00:00:00:00:00:03', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[device_tracker.pc_home-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'PC_HOME', + 'icon': 'mdi:desktop-tower', + 'ip': '192.168.50.3', + 'mac': '00:00:00:00:00:03', + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.pc_home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'home', + }) +# --- +# name: test_all_entities[device_tracker.samsung_the_frame_55-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.samsung_the_frame_55', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:television', + 'original_name': 'Samsung The Frame 55', + 'platform': 'fing', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '0000000000XX-00:00:00:00:00:02', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[device_tracker.samsung_the_frame_55-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Samsung The Frame 55', + 'icon': 'mdi:television', + 'ip': '192.168.50.2', + 'mac': '00:00:00:00:00:02', + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.samsung_the_frame_55', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'home', + }) +# --- diff --git a/tests/components/fing/test_config_flow.py b/tests/components/fing/test_config_flow.py new file mode 100644 index 0000000000000..4d39d092cd5e5 --- /dev/null +++ b/tests/components/fing/test_config_flow.py @@ -0,0 +1,154 @@ +"""Tests for Fing config flow.""" + +import httpx +import pytest + +from homeassistant.components.fing.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_IP_ADDRESS, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import AsyncMock +from tests.conftest import MockConfigEntry + + +async def test_verify_connection_success( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mocked_fing_agent: AsyncMock, +) -> None: + """Test successful connection verification.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_IP_ADDRESS: "192.168.1.1", + CONF_PORT: "49090", + CONF_API_KEY: "test_key", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == dict(mock_config_entry.data) + + entry = result["result"] + assert entry.unique_id == "0000000000XX" + assert entry.domain == DOMAIN + + +@pytest.mark.parametrize("api_type", ["old"]) +async def test_verify_api_version_outdated( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mocked_fing_agent: AsyncMock, +) -> None: + """Test connection verification failure.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_IP_ADDRESS: "192.168.1.1", + CONF_PORT: "49090", + CONF_API_KEY: "test_key", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "api_version_error" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (httpx.NetworkError("Network error"), "cannot_connect"), + (httpx.TimeoutException("Timeout error"), "timeout_connect"), + ( + httpx.HTTPStatusError( + "HTTP status error - 500", request=None, response=httpx.Response(500) + ), + "http_status_error", + ), + ( + httpx.HTTPStatusError( + "HTTP status error - 401", request=None, response=httpx.Response(401) + ), + "invalid_api_key", + ), + (httpx.HTTPError("HTTP error"), "unknown"), + (httpx.InvalidURL("Invalid URL"), "url_error"), + (httpx.CookieConflict("Cookie conflict"), "unknown"), + (httpx.StreamError("Stream error"), "unknown"), + ], +) +async def test_http_error_handling( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mocked_fing_agent: AsyncMock, + error: str, + exception: Exception, +) -> None: + """Test handling of HTTP-related errors during connection verification.""" + mocked_fing_agent.get_devices.side_effect = exception + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_IP_ADDRESS: "192.168.1.1", + CONF_PORT: "49090", + CONF_API_KEY: "test_key", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == error + + # Simulate a successful connection after the error + mocked_fing_agent.get_devices.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_IP_ADDRESS: "192.168.1.1", + CONF_PORT: "49090", + CONF_API_KEY: "test_key", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == dict(mock_config_entry.data) + + +async def test_duplicate_entries( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mocked_fing_agent: AsyncMock, +) -> None: + """Test detecting duplicate entries.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_IP_ADDRESS: "192.168.1.1", + CONF_PORT: "49090", + CONF_API_KEY: "test_key", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/fing/test_device_tracker.py b/tests/components/fing/test_device_tracker.py new file mode 100644 index 0000000000000..bf8f9a0bd5add --- /dev/null +++ b/tests/components/fing/test_device_tracker.py @@ -0,0 +1,127 @@ +"""Test Fing Agent device tracker entity.""" + +from datetime import timedelta + +from fing_agent_api.models import DeviceResponse +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.fing.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import ( + AsyncMock, + async_fire_time_changed, + async_load_json_object_fixture, + snapshot_platform, +) +from tests.conftest import MockConfigEntry, SnapshotAssertion + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + mocked_fing_agent: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities created by Fing with snapshot.""" + entry = await init_integration(hass, mock_config_entry, mocked_fing_agent) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +async def test_new_device_found( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mocked_fing_agent: AsyncMock, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test Fing device tracker setup.""" + + delta_time = timedelta(seconds=35) # 30 seconds + 5 delta seconds + + await hass.config.async_set_time_zone("UTC") + freezer.move_to("2021-01-09 12:00:00+00:00") + entry = await init_integration(hass, mock_config_entry, mocked_fing_agent) + + # First check -> there are 3 devices in total + assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 3 + + mocked_fing_agent.get_devices.return_value = DeviceResponse( + await async_load_json_object_fixture( + hass, "device_resp_device_added.json", DOMAIN + ) + ) + + freezer.tick(delta_time) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Second check -> added one device + assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 4 + + mocked_fing_agent.get_devices.return_value = DeviceResponse( + await async_load_json_object_fixture( + hass, "device_resp_device_deleted.json", DOMAIN + ) + ) + + freezer.tick(delta_time) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Third check -> removed two devices (old devices) + assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 2 + + +async def test_device_modified( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mocked_fing_agent: AsyncMock, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test Fing device modified.""" + + delta_time = timedelta(seconds=35) # 30 seconds + 5 delta seconds + + # ---------------------------------------------------- + + await hass.config.async_set_time_zone("UTC") + freezer.move_to("2021-01-09 12:00:00+00:00") + entry = await init_integration(hass, mock_config_entry, mocked_fing_agent) + + old_entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) + + # ---------------------------------------------------- + + mocked_fing_agent.get_devices.return_value = DeviceResponse( + await async_load_json_object_fixture( + hass, "device_resp_device_modified.json", DOMAIN + ) + ) + + freezer.tick(delta_time) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + new_entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) + + # ---------------------------------------------------- + + assert len(old_entries) == len(new_entries) + + old_entries_by_ids = {e.unique_id: e for e in old_entries} + new_entries_by_ids = {e.unique_id: e for e in new_entries} + + unique_id = "0000000000XX-00:00:00:00:00:03" + + old_entry = old_entries_by_ids[unique_id] + new_entry = new_entries_by_ids[unique_id] + + assert old_entry.original_name != new_entry.original_name + assert old_entry.original_icon != new_entry.original_icon diff --git a/tests/components/fing/test_init.py b/tests/components/fing/test_init.py new file mode 100644 index 0000000000000..b0413f85557c4 --- /dev/null +++ b/tests/components/fing/test_init.py @@ -0,0 +1,47 @@ +"""Test the Fing integration init.""" + +import pytest + +from homeassistant.components.fing.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import init_integration + +from tests.common import AsyncMock +from tests.conftest import MockConfigEntry + + +@pytest.mark.parametrize("api_type", ["new", "old"]) +async def test_setup_entry_new_api( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mocked_fing_agent: AsyncMock, + api_type: str, +) -> None: + """Test setup Fing Agent /w New API.""" + entry = await init_integration(hass, mock_config_entry, mocked_fing_agent) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + if api_type == "new": + assert entry.state is ConfigEntryState.LOADED + elif api_type == "old": + assert entry.state is ConfigEntryState.SETUP_ERROR + + +@pytest.mark.parametrize("api_type", ["new"]) +async def test_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mocked_fing_agent: AsyncMock, +) -> None: + """Test unload of entry.""" + entry = await init_integration(hass, mock_config_entry, mocked_fing_agent) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED From 33ed8514776cbbf912aa05d0d3a11617185cc6bc Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 24 Oct 2025 10:11:54 -0400 Subject: [PATCH 13/17] Increase AI Task default tokens for Google Gemini (#155065) --- .../google_generative_ai_conversation/ai_task.py | 12 ++++++++++-- .../google_generative_ai_conversation/const.py | 2 ++ .../google_generative_ai_conversation/entity.py | 12 ++++++++++-- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/ai_task.py b/homeassistant/components/google_generative_ai_conversation/ai_task.py index 1703aab167813..8355a3d260ed3 100644 --- a/homeassistant/components/google_generative_ai_conversation/ai_task.py +++ b/homeassistant/components/google_generative_ai_conversation/ai_task.py @@ -15,7 +15,13 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.json import json_loads -from .const import CONF_CHAT_MODEL, CONF_RECOMMENDED, LOGGER, RECOMMENDED_IMAGE_MODEL +from .const import ( + CONF_CHAT_MODEL, + CONF_RECOMMENDED, + LOGGER, + RECOMMENDED_A_TASK_MAX_TOKENS, + RECOMMENDED_IMAGE_MODEL, +) from .entity import ( ERROR_GETTING_RESPONSE, GoogleGenerativeAILLMBaseEntity, @@ -73,7 +79,9 @@ async def _async_generate_data( chat_log: conversation.ChatLog, ) -> ai_task.GenDataTaskResult: """Handle a generate data task.""" - await self._async_handle_chat_log(chat_log, task.structure) + await self._async_handle_chat_log( + chat_log, task.structure, default_max_tokens=RECOMMENDED_A_TASK_MAX_TOKENS + ) if not isinstance(chat_log.content[-1], conversation.AssistantContent): LOGGER.error( diff --git a/homeassistant/components/google_generative_ai_conversation/const.py b/homeassistant/components/google_generative_ai_conversation/const.py index 1960e2bffdc8b..d8b4c8e1b2134 100644 --- a/homeassistant/components/google_generative_ai_conversation/const.py +++ b/homeassistant/components/google_generative_ai_conversation/const.py @@ -32,6 +32,8 @@ RECOMMENDED_TOP_K = 64 CONF_MAX_TOKENS = "max_tokens" RECOMMENDED_MAX_TOKENS = 3000 +# Input 5000, output 19400 = 0.05 USD +RECOMMENDED_A_TASK_MAX_TOKENS = 19400 CONF_HARASSMENT_BLOCK_THRESHOLD = "harassment_block_threshold" CONF_HATE_BLOCK_THRESHOLD = "hate_block_threshold" CONF_SEXUAL_BLOCK_THRESHOLD = "sexual_block_threshold" diff --git a/homeassistant/components/google_generative_ai_conversation/entity.py b/homeassistant/components/google_generative_ai_conversation/entity.py index 54ef22bd1a58e..12c8e0229e4f6 100644 --- a/homeassistant/components/google_generative_ai_conversation/entity.py +++ b/homeassistant/components/google_generative_ai_conversation/entity.py @@ -472,6 +472,7 @@ async def _async_handle_chat_log( self, chat_log: conversation.ChatLog, structure: vol.Schema | None = None, + default_max_tokens: int | None = None, ) -> None: """Generate an answer for the chat log.""" options = self.subentry.data @@ -618,7 +619,9 @@ async def _async_handle_chat_log( if not chat_log.unresponded_tool_results: break - def create_generate_content_config(self) -> GenerateContentConfig: + def create_generate_content_config( + self, default_max_tokens: int | None = None + ) -> GenerateContentConfig: """Create the GenerateContentConfig for the LLM.""" options = self.subentry.data model = options.get(CONF_CHAT_MODEL, self.default_model) @@ -632,7 +635,12 @@ def create_generate_content_config(self) -> GenerateContentConfig: temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), top_k=options.get(CONF_TOP_K, RECOMMENDED_TOP_K), top_p=options.get(CONF_TOP_P, RECOMMENDED_TOP_P), - max_output_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), + max_output_tokens=options.get( + CONF_MAX_TOKENS, + default_max_tokens + if default_max_tokens is not None + else RECOMMENDED_MAX_TOKENS, + ), safety_settings=[ SafetySetting( category=HarmCategory.HARM_CATEGORY_HATE_SPEECH, From 1d8eaeb8af9974bbe2cd291fe0583206d62d6bfa Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Fri, 24 Oct 2025 17:19:45 +0300 Subject: [PATCH 14/17] Fix OpenRGB tests failing CI (#155095) --- tests/components/openrgb/test_light.py | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/tests/components/openrgb/test_light.py b/tests/components/openrgb/test_light.py index 7e1be565049a8..b32c20a309b26 100644 --- a/tests/components/openrgb/test_light.py +++ b/tests/components/openrgb/test_light.py @@ -376,8 +376,7 @@ async def test_turn_on_restores_previous_values( freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) - await hass.async_block_till_done() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get("light.ene_dram") assert state @@ -428,8 +427,7 @@ async def test_previous_values_updated_on_refresh( freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) - await hass.async_block_till_done() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) # Verify new state state = hass.states.get("light.ene_dram") @@ -444,8 +442,7 @@ async def test_previous_values_updated_on_refresh( freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) - await hass.async_block_till_done() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) # Verify light is off state = hass.states.get("light.ene_dram") @@ -492,8 +489,7 @@ async def test_turn_on_restores_rainbow_after_off( freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) - await hass.async_block_till_done() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) # Verify light is off state = hass.states.get("light.ene_dram") @@ -543,8 +539,7 @@ async def test_turn_on_restores_rainbow_after_off_by_color( freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) - await hass.async_block_till_done() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) # Verify light is off state = hass.states.get("light.ene_dram") @@ -777,8 +772,7 @@ async def test_dynamic_device_addition( freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) - await hass.async_block_till_done() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) # Check that second light entity was added state = hass.states.get("light.new_rgb_device") @@ -802,9 +796,7 @@ async def test_light_availability( freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) - await hass.async_block_till_done() - await hass.async_block_till_done() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get("light.ene_dram") assert state From 2d84bd65fe55d501332adf943ed0cac8c4efcfe0 Mon Sep 17 00:00:00 2001 From: hanwg Date: Fri, 24 Oct 2025 22:29:27 +0800 Subject: [PATCH 15/17] Fix send_poll action for Telegram bot (#155076) --- homeassistant/components/telegram_bot/services.yaml | 9 ++++----- homeassistant/components/telegram_bot/strings.json | 4 ---- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml index 5b146e3c4505b..5da096c3e3311 100644 --- a/homeassistant/components/telegram_bot/services.yaml +++ b/homeassistant/components/telegram_bot/services.yaml @@ -690,10 +690,13 @@ send_poll: selector: text: options: + example: '["Option 1", "Option 2", "Option 3"]' required: true selector: - object: + text: + multiple: true is_anonymous: + default: true selector: boolean: allows_multiple_answers: @@ -714,10 +717,6 @@ send_poll: min: 1 max: 3600 unit_of_measurement: seconds - message_tag: - example: "msg_to_edit" - selector: - text: reply_to_message_id: selector: number: diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index 28a31809f27be..07fa777be3b40 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -801,10 +801,6 @@ "name": "Read timeout", "description": "Timeout for sending the poll in seconds." }, - "message_tag": { - "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", - "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" - }, "reply_to_message_id": { "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" From c38e02072e8e264cdd5ba06c081f8d0f80b56220 Mon Sep 17 00:00:00 2001 From: Felipe Santos Date: Fri, 24 Oct 2025 11:33:54 -0300 Subject: [PATCH 16/17] Set Prettier as default formatter in VS Code for JSON and YAML (#154484) --- .devcontainer/devcontainer.json | 5 ++++- .gitignore | 1 + ...gs.default.json => settings.default.jsonc} | 20 +++++++++++-------- script/setup | 2 +- 4 files changed, 18 insertions(+), 10 deletions(-) rename .vscode/{settings.default.json => settings.default.jsonc} (60%) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index ba22488f2124f..b59eef2410e2c 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -33,7 +33,7 @@ "GitHub.vscode-pull-request-github", "GitHub.copilot" ], - // Please keep this file in sync with settings in home-assistant/.vscode/settings.default.json + // Please keep this file in sync with settings in home-assistant/.vscode/settings.default.jsonc "settings": { "python.experiments.optOutFrom": ["pythonTestAdapter"], "python.defaultInterpreterPath": "/home/vscode/.local/ha-venv/bin/python", @@ -63,6 +63,9 @@ "[python]": { "editor.defaultFormatter": "charliermarsh.ruff" }, + "[json][jsonc][yaml]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, "json.schemas": [ { "fileMatch": ["homeassistant/components/*/manifest.json"], diff --git a/.gitignore b/.gitignore index a8bafbc343e83..81f32c69edad1 100644 --- a/.gitignore +++ b/.gitignore @@ -111,6 +111,7 @@ virtualization/vagrant/config !.vscode/cSpell.json !.vscode/extensions.json !.vscode/tasks.json +!.vscode/settings.default.jsonc .env # Windows Explorer diff --git a/.vscode/settings.default.json b/.vscode/settings.default.jsonc similarity index 60% rename from .vscode/settings.default.json rename to .vscode/settings.default.jsonc index 1fae0e5591279..3d04c152a49d8 100644 --- a/.vscode/settings.default.json +++ b/.vscode/settings.default.jsonc @@ -9,13 +9,17 @@ "pylint.importStrategy": "fromEnvironment", // Pyright is too pedantic for Home Assistant "python.analysis.typeCheckingMode": "basic", + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff" + }, + "[json][jsonc][yaml]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, "json.schemas": [ - { - "fileMatch": [ - "homeassistant/components/*/manifest.json" - ], - // This value differs between working with devcontainer and locally, therefor this value should NOT be in sync! - "url": "./script/json_schemas/manifest_schema.json" - } - ] + { + "fileMatch": ["homeassistant/components/*/manifest.json"], + // This value differs between working with devcontainer and locally, therefore this value should NOT be in sync! + "url": "./script/json_schemas/manifest_schema.json" + } + ] } diff --git a/script/setup b/script/setup index 00600b3c1acd7..1fd61aa9b71fa 100755 --- a/script/setup +++ b/script/setup @@ -8,7 +8,7 @@ cd "$(dirname "$0")/.." # Add default vscode settings if not existing SETTINGS_FILE=./.vscode/settings.json -SETTINGS_TEMPLATE_FILE=./.vscode/settings.default.json +SETTINGS_TEMPLATE_FILE=./.vscode/settings.default.jsonc if [ ! -f "$SETTINGS_FILE" ]; then echo "Copy $SETTINGS_TEMPLATE_FILE to $SETTINGS_FILE." cp "$SETTINGS_TEMPLATE_FILE" "$SETTINGS_FILE" From 4d525dee4898b146742201c534082f045ecb5763 Mon Sep 17 00:00:00 2001 From: MoonDevLT <107535193+MoonDevLT@users.noreply.github.com> Date: Fri, 24 Oct 2025 16:40:42 +0200 Subject: [PATCH 17/17] Add dimming functionality to the Lunatone light entity (#154508) Co-authored-by: Dave T <17680170+davet2001@users.noreply.github.com> --- homeassistant/components/lunatone/light.py | 51 +++++++++++++++++-- tests/components/lunatone/conftest.py | 6 +++ tests/components/lunatone/test_light.py | 59 +++++++++++++++++++++- 3 files changed, 109 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/lunatone/light.py b/homeassistant/components/lunatone/light.py index 416412aea6e1d..16f2c5384ced1 100644 --- a/homeassistant/components/lunatone/light.py +++ b/homeassistant/components/lunatone/light.py @@ -5,11 +5,17 @@ import asyncio from typing import Any -from homeassistant.components.light import ColorMode, LightEntity +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ColorMode, + LightEntity, + brightness_supported, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util.color import brightness_to_value, value_to_brightness from .const import DOMAIN from .coordinator import LunatoneConfigEntry, LunatoneDevicesDataUpdateCoordinator @@ -42,8 +48,10 @@ class LunatoneLight( ): """Representation of a Lunatone light.""" - _attr_color_mode = ColorMode.ONOFF - _attr_supported_color_modes = {ColorMode.ONOFF} + BRIGHTNESS_SCALE = (1, 100) + + _last_brightness = 255 + _attr_has_entity_name = True _attr_name = None _attr_should_poll = False @@ -82,6 +90,25 @@ def is_on(self) -> bool: """Return True if light is on.""" return self._device is not None and self._device.is_on + @property + def brightness(self) -> int: + """Return the brightness of this light between 0..255.""" + if self._device is None: + return 0 + return value_to_brightness(self.BRIGHTNESS_SCALE, self._device.brightness) + + @property + def color_mode(self) -> ColorMode: + """Return the color mode of the light.""" + if self._device is not None and self._device.is_dimmable: + return ColorMode.BRIGHTNESS + return ColorMode.ONOFF + + @property + def supported_color_modes(self) -> set[ColorMode]: + """Return the supported color modes.""" + return {self.color_mode} + @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" @@ -91,13 +118,27 @@ def _handle_coordinator_update(self) -> None: async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" assert self._device - await self._device.switch_on() + + if brightness_supported(self.supported_color_modes): + brightness = kwargs.get(ATTR_BRIGHTNESS, self._last_brightness) + await self._device.fade_to_brightness( + brightness_to_value(self.BRIGHTNESS_SCALE, brightness) + ) + else: + await self._device.switch_on() + await asyncio.sleep(STATUS_UPDATE_DELAY) await self.coordinator.async_refresh() async def async_turn_off(self, **kwargs: Any) -> None: """Instruct the light to turn off.""" assert self._device - await self._device.switch_off() + + if brightness_supported(self.supported_color_modes): + self._last_brightness = self.brightness + await self._device.fade_to_brightness(0) + else: + await self._device.switch_off() + await asyncio.sleep(STATUS_UPDATE_DELAY) await self.coordinator.async_refresh() diff --git a/tests/components/lunatone/conftest.py b/tests/components/lunatone/conftest.py index 21a80891dd911..89e3adfc0bf68 100644 --- a/tests/components/lunatone/conftest.py +++ b/tests/components/lunatone/conftest.py @@ -27,6 +27,7 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture def mock_lunatone_devices() -> Generator[AsyncMock]: """Mock a Lunatone devices object.""" + state = {"is_dimmable": False} def build_devices_mock(devices: Devices): device_list = [] @@ -36,6 +37,10 @@ def build_devices_mock(devices: Devices): device.id = device.data.id device.name = device.data.name device.is_on = device.data.features.switchable.status + device.brightness = device.data.features.dimmable.status + type(device).is_dimmable = PropertyMock( + side_effect=lambda s=state: s["is_dimmable"] + ) device_list.append(device) return device_list @@ -47,6 +52,7 @@ def build_devices_mock(devices: Devices): type(devices).devices = PropertyMock( side_effect=lambda d=devices: build_devices_mock(d) ) + devices.set_is_dimmable = lambda value, s=state: s.update(is_dimmable=value) yield devices diff --git a/tests/components/lunatone/test_light.py b/tests/components/lunatone/test_light.py index 64262ad497b7a..24065d1004941 100644 --- a/tests/components/lunatone/test_light.py +++ b/tests/components/lunatone/test_light.py @@ -4,7 +4,7 @@ from syrupy.assertion import SnapshotAssertion -from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.light import ATTR_BRIGHTNESS, DOMAIN as LIGHT_DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -47,7 +47,6 @@ async def test_turn_on_off( mock_lunatone_devices: AsyncMock, mock_lunatone_info: AsyncMock, mock_config_entry: MockConfigEntry, - entity_registry: er.EntityRegistry, ) -> None: """Test the light can be turned on and off.""" await setup_integration(hass, mock_config_entry) @@ -77,3 +76,59 @@ async def fake_update(): state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_OFF + + +async def test_turn_on_off_with_brightness( + hass: HomeAssistant, + mock_lunatone_devices: AsyncMock, + mock_lunatone_info: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the light can be turned on with brightness.""" + expected_brightness = 128 + brightness_percentages = iter([50.0, 0.0, 50.0]) + + mock_lunatone_devices.set_is_dimmable(True) + + await setup_integration(hass, mock_config_entry) + + async def fake_update(): + brightness = next(brightness_percentages) + device = mock_lunatone_devices.data.devices[0] + device.features.switchable.status = brightness > 0 + device.features.dimmable.status = brightness + + mock_lunatone_devices.async_update.side_effect = fake_update + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_BRIGHTNESS: expected_brightness}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_ON + assert state.attributes["brightness"] == expected_brightness + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF + assert not state.attributes["brightness"] + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_ON + assert state.attributes["brightness"] == expected_brightness