diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index ba22488f2124ff..b59eef2410e2c6 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 a8bafbc343e83e..81f32c69edad14 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 1fae0e5591279b..3d04c152a49d8c 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/CODEOWNERS b/CODEOWNERS
index 9cfa84d0e93684..696b7a0f2f0249 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/airzone/manifest.json b/homeassistant/components/airzone/manifest.json
index 6e4b0b50c4c028..6134f6c1a5d89e 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/homeassistant/components/fing/__init__.py b/homeassistant/components/fing/__init__.py
new file mode 100644
index 00000000000000..699bc447cf0088
--- /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 00000000000000..0c99f7e34dbb87
--- /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 00000000000000..65b5d27e558044
--- /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 00000000000000..84d44ce5d73502
--- /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 00000000000000..c87fd123a8c588
--- /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 00000000000000..a517b12d2324c3
--- /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 00000000000000..273190261d7579
--- /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 00000000000000..347deb1fbdbcfe
--- /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 00000000000000..3f59e01fd23d01
--- /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/components/google_generative_ai_conversation/ai_task.py b/homeassistant/components/google_generative_ai_conversation/ai_task.py
index 1703aab1678135..8355a3d260ed30 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 1960e2bffdc8b6..d8b4c8e1b21347 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 54ef22bd1a58ed..12c8e0229e4f6e 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,
diff --git a/homeassistant/components/huum/climate.py b/homeassistant/components/huum/climate.py
index af4e8cc3623ff1..c520880e6919c5 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 177c035f0415a3..28609a7e920244 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/homeassistant/components/lunatone/light.py b/homeassistant/components/lunatone/light.py
index 416412aea6e1d0..16f2c5384ced16 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/homeassistant/components/probe_plus/coordinator.py b/homeassistant/components/probe_plus/coordinator.py
index b712e3fc84bf08..1e37340726b1d0 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 01a0d94b15385a..160c231897c20e 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 45fd4be39ce0c6..bd2ccac851463f 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/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json
index 2d741c1301bc89..27f5e46821e34f 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/homeassistant/components/system_bridge/binary_sensor.py b/homeassistant/components/system_bridge/binary_sensor.py
index 0140499a75a5ea..883c74f2589d98 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/config_flow.py b/homeassistant/components/system_bridge/config_flow.py
index 60b57b1e87fa14..fae75f4087e6be 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 235d7e6b9869d7..72160b86daae77 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/icons.json b/homeassistant/components/system_bridge/icons.json
index a03f77049a315d..0be0a2d7c028f8 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 6d3bbd21a05fb6..2be2f06c1e7761 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 8ad3ede39606bc..f07c96fe8caad8 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],
diff --git a/homeassistant/components/system_bridge/strings.json b/homeassistant/components/system_bridge/strings.json
index 0cca826684a674..7dd46aa0ecbf49 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}."
}
}
},
diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml
index 5b146e3c4505bf..5da096c3e33114 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 28a31809f27bec..07fa777be3b40f 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%]"
diff --git a/homeassistant/components/tesla_fleet/coordinator.py b/homeassistant/components/tesla_fleet/coordinator.py
index 4ab332c91525a6..59d1c250703584 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 eed00ebc64f802..a73424bdd288df 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/homeassistant/components/vicare/entity.py b/homeassistant/components/vicare/entity.py
index 6dd986c3e2414d..57143f7c153562 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
diff --git a/homeassistant/components/yardian/__init__.py b/homeassistant/components/yardian/__init__.py
index eafdc8e68db7c6..7942d77ac3fc74 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 00000000000000..e9afa44662b28d
--- /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 da016ca4ec4e1d..6a45c7012b225a 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 4ca3d83bd158c6..05b0ecb388118e 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 fcaef65ee3ef44..226285e1e952d8 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/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json
index 70ea973c3c81b4..46cb9fa50734d5 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"
diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py
index 30799f93a93949..f3d23183f55737 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 ee3c036109adc7..7b933a735bf7f7 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 bd89be0ef56b6c..18b23b89933cad 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
@@ -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
@@ -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
@@ -2305,7 +2308,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 5c481dcfc3bb09..cc206d4ed73908 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
@@ -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
@@ -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
@@ -1932,7 +1935,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/script/setup b/script/setup
index 00600b3c1acd75..1fd61aa9b71fab 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"
diff --git a/tests/components/airzone/snapshots/test_diagnostics.ambr b/tests/components/airzone/snapshots/test_diagnostics.ambr
index 0895073e0aaf37..19c05f52d8dcd1 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,
}),
}),
diff --git a/tests/components/fing/__init__.py b/tests/components/fing/__init__.py
new file mode 100644
index 00000000000000..e3ef8294d3192c
--- /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 00000000000000..6b48827152a42e
--- /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 00000000000000..acf398b73bc3dd
--- /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 00000000000000..69633d497250c5
--- /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 00000000000000..e630baa426ccd0
--- /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 00000000000000..f57cdcf8da473e
--- /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 00000000000000..d4d1120c027b24
--- /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 00000000000000..39399b4a2bbc79
--- /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 00000000000000..cf56171c26ea69
--- /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 00000000000000..4d39d092cd5e5f
--- /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 00000000000000..bf8f9a0bd5add8
--- /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 00000000000000..b0413f85557c47
--- /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
diff --git a/tests/components/huum/test_climate.py b/tests/components/huum/test_climate.py
index ca7fcf81185124..3e0a07f92325f9 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
diff --git a/tests/components/lunatone/conftest.py b/tests/components/lunatone/conftest.py
index 21a80891dd9117..89e3adfc0bf68a 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 64262ad497b7a4..24065d1004941b 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
diff --git a/tests/components/openrgb/test_light.py b/tests/components/openrgb/test_light.py
index 7e1be565049a82..b32c20a309b267 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
diff --git a/tests/components/tesla_fleet/snapshots/test_sensor.ambr b/tests/components/tesla_fleet/snapshots/test_sensor.ambr
index eab1441399fb7d..8db495adfab421 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]
diff --git a/tests/components/yardian/__init__.py b/tests/components/yardian/__init__.py
index 47f8cbc509e099..2f8936b7cae669 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 00e76c4c34ff3a..fa85caffea5a49 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 00000000000000..a9373348d5a1fa
--- /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/snapshots/test_switch.ambr b/tests/components/yardian/snapshots/test_switch.ambr
new file mode 100644
index 00000000000000..585dcc6cfeffa1
--- /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_binary_sensor.py b/tests/components/yardian/test_binary_sensor.py
new file mode 100644
index 00000000000000..61f6dbce5de4de
--- /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 00000000000000..c798faf0adb8a2
--- /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
diff --git a/tests/components/yardian/test_switch.py b/tests/components/yardian/test_switch.py
new file mode 100644
index 00000000000000..eb648eae0073dd
--- /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()