Merge branch 'dev' into fix-microsign-alt1

This commit is contained in:
Jan Bouwhuis 2025-07-07 19:34:15 +02:00 committed by GitHub
commit cd61fc93a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
68 changed files with 2224 additions and 1145 deletions

View File

@ -7,7 +7,7 @@
"iot_class": "local_polling",
"loggers": ["pyenphase"],
"quality_scale": "platinum",
"requirements": ["pyenphase==2.2.0"],
"requirements": ["pyenphase==2.2.1"],
"zeroconf": [
{
"type": "_enphase-envoy._tcp.local."

View File

@ -7,6 +7,7 @@ from typing import Final
import voluptuous as vol
from homeassistant.components.zone import condition as zone_condition
from homeassistant.const import CONF_EVENT, CONF_PLATFORM, CONF_SOURCE, CONF_ZONE
from homeassistant.core import (
CALLBACK_TYPE,
@ -17,7 +18,7 @@ from homeassistant.core import (
State,
callback,
)
from homeassistant.helpers import condition, config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.config_validation import entity_domain
from homeassistant.helpers.event import TrackStates, async_track_state_change_filtered
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
@ -79,9 +80,11 @@ async def async_attach_trigger(
return
from_match = (
condition.zone(hass, zone_state, from_state) if from_state else False
zone_condition.zone(hass, zone_state, from_state) if from_state else False
)
to_match = (
zone_condition.zone(hass, zone_state, to_state) if to_state else False
)
to_match = condition.zone(hass, zone_state, to_state) if to_state else False
if (trigger_event == EVENT_ENTER and not from_match and to_match) or (
trigger_event == EVENT_LEAVE and from_match and not to_match

View File

@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["dacite", "gios"],
"requirements": ["gios==6.0.0"]
"requirements": ["gios==6.1.0"]
}

View File

@ -80,10 +80,10 @@ async def async_send_text_commands(
credentials = Credentials(session.token[CONF_ACCESS_TOKEN]) # type: ignore[no-untyped-call]
language_code = entry.options.get(CONF_LANGUAGE_CODE, default_language_code(hass))
command_response_list = []
with TextAssistant(
credentials, language_code, audio_out=bool(media_players)
) as assistant:
command_response_list = []
for command in commands:
try:
resp = await hass.async_add_executor_job(assistant.assist, command)
@ -117,7 +117,7 @@ async def async_send_text_commands(
blocking=True,
)
command_response_list.append(CommandResponse(text_response))
return command_response_list
return command_response_list
def default_language_code(hass: HomeAssistant) -> str:

View File

@ -7,6 +7,6 @@
"documentation": "https://www.home-assistant.io/integrations/google_assistant_sdk",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["gassist-text==0.0.12"],
"requirements": ["gassist-text==0.0.14"],
"single_config_entry": true
}

View File

@ -44,11 +44,14 @@ from homeassistant.helpers.entity_component import async_update_entity
from homeassistant.helpers.issue_registry import IssueSeverity
from homeassistant.helpers.service import (
async_extract_config_entry_ids,
async_extract_referenced_entity_ids,
async_register_admin_service,
)
from homeassistant.helpers.signal import KEY_HA_STOP
from homeassistant.helpers.system_info import async_get_system_info
from homeassistant.helpers.target import (
TargetSelectorData,
async_extract_referenced_entity_ids,
)
from homeassistant.helpers.template import async_load_custom_templates
from homeassistant.helpers.typing import ConfigType
@ -111,7 +114,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
async def async_handle_turn_service(service: ServiceCall) -> None:
"""Handle calls to homeassistant.turn_on/off."""
referenced = async_extract_referenced_entity_ids(hass, service)
referenced = async_extract_referenced_entity_ids(
hass, TargetSelectorData(service.data)
)
all_referenced = referenced.referenced | referenced.indirectly_referenced
# Generic turn on/off method requires entity id

View File

@ -75,11 +75,12 @@ from homeassistant.helpers.entityfilter import (
EntityFilter,
)
from homeassistant.helpers.reload import async_integration_yaml_config
from homeassistant.helpers.service import (
async_extract_referenced_entity_ids,
async_register_admin_service,
)
from homeassistant.helpers.service import async_register_admin_service
from homeassistant.helpers.start import async_at_started
from homeassistant.helpers.target import (
TargetSelectorData,
async_extract_referenced_entity_ids,
)
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import IntegrationNotFound, async_get_integration
from homeassistant.util.async_ import create_eager_task
@ -482,7 +483,9 @@ def _async_register_events_and_services(hass: HomeAssistant) -> None:
async def async_handle_homekit_unpair(service: ServiceCall) -> None:
"""Handle unpair HomeKit service call."""
referenced = async_extract_referenced_entity_ids(hass, service)
referenced = async_extract_referenced_entity_ids(
hass, TargetSelectorData(service.data)
)
dev_reg = dr.async_get(hass)
for device_id in referenced.referenced_devices:
if not (dev_reg_ent := dev_reg.async_get(device_id)):

View File

@ -37,12 +37,7 @@ from homeassistant.const import (
)
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import (
config_validation as cv,
frame,
issue_registry as ir,
storage,
)
from homeassistant.helpers import config_validation as cv, issue_registry as ir, storage
from homeassistant.helpers.http import (
KEY_ALLOW_CONFIGURED_CORS,
KEY_AUTHENTICATED, # noqa: F401
@ -505,25 +500,6 @@ class HomeAssistantHTTP:
)
)
def register_static_path(
self, url_path: str, path: str, cache_headers: bool = True
) -> None:
"""Register a folder or file to serve as a static path."""
frame.report_usage(
"calls hass.http.register_static_path which "
"does blocking I/O in the event loop, instead "
"call `await hass.http.async_register_static_paths("
f'[StaticPathConfig("{url_path}", "{path}", {cache_headers})])`',
exclude_integrations={"http"},
core_behavior=frame.ReportBehavior.ERROR,
core_integration_behavior=frame.ReportBehavior.ERROR,
custom_integration_behavior=frame.ReportBehavior.ERROR,
breaks_in_ha_version="2025.7",
)
configs = [StaticPathConfig(url_path, path, cache_headers)]
resources = self._make_static_resources(configs)
self._async_register_static_paths(configs, resources)
def _create_ssl_context(self) -> ssl.SSLContext | None:
context: ssl.SSLContext | None = None
assert self.ssl_certificate is not None

View File

@ -2,8 +2,8 @@
"config": {
"step": {
"user": {
"title": "Configure Iskra Device",
"description": "Enter the IP address of your Iskra Device and select protocol.",
"title": "Configure Iskra device",
"description": "Enter the IP address of your Iskra device and select protocol.",
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
@ -12,7 +12,7 @@
}
},
"authentication": {
"title": "Configure Rest API Credentials",
"title": "Configure REST API credentials",
"description": "Enter username and password",
"data": {
"username": "[%key:common::config_flow::data::username%]",
@ -44,7 +44,7 @@
"selector": {
"protocol": {
"options": {
"rest_api": "Rest API",
"rest_api": "REST API",
"modbus_tcp": "Modbus TCP"
}
}

View File

@ -28,7 +28,10 @@ from homeassistant.components.light import (
from homeassistant.const import ATTR_MODE
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.service import async_extract_referenced_entity_ids
from homeassistant.helpers.target import (
TargetSelectorData,
async_extract_referenced_entity_ids,
)
from .const import _ATTR_COLOR_TEMP, ATTR_THEME, DOMAIN
from .coordinator import LIFXConfigEntry, LIFXUpdateCoordinator
@ -268,7 +271,9 @@ class LIFXManager:
async def service_handler(service: ServiceCall) -> None:
"""Apply a service, i.e. start an effect."""
referenced = async_extract_referenced_entity_ids(self.hass, service)
referenced = async_extract_referenced_entity_ids(
self.hass, TargetSelectorData(service.data)
)
all_referenced = referenced.referenced | referenced.indirectly_referenced
if all_referenced:
await self.start_effect(all_referenced, service.service, **service.data)
@ -499,6 +504,5 @@ class LIFXManager:
if self.entry_id_to_entity_id[entry.entry_id] in entity_ids:
coordinators.append(entry.runtime_data)
bulbs.append(entry.runtime_data.device)
if start_effect_func := self._effect_dispatch.get(service):
await start_effect_func(self, bulbs, coordinators, **kwargs)

View File

@ -309,7 +309,7 @@ DISCOVERY_SCHEMAS = [
platform=Platform.BINARY_SENSOR,
entity_description=MatterBinarySensorEntityDescription(
key="EnergyEvseSupplyStateSensor",
translation_key="evse_supply_charging_state",
translation_key="evse_supply_state",
device_class=BinarySensorDeviceClass.RUNNING,
device_to_ha={
clusters.EnergyEvse.Enums.SupplyStateEnum.kDisabled: False,

View File

@ -83,8 +83,8 @@
"evse_plug": {
"name": "Plug state"
},
"evse_supply_charging_state": {
"name": "Supply charging state"
"evse_supply_state": {
"name": "Charger supply state"
},
"boost_state": {
"name": "Boost state"

View File

@ -10,12 +10,19 @@ from typing import Any
from nibe.coil import Coil, CoilData
from nibe.connection import Connection
from nibe.exceptions import CoilNotFoundException, ReadException
from nibe.exceptions import (
CoilNotFoundException,
ReadException,
WriteDeniedException,
WriteException,
WriteTimeoutException,
)
from nibe.heatpump import HeatPump, Series
from propcache.api import cached_property
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@ -134,7 +141,36 @@ class CoilCoordinator(ContextCoordinator[dict[int, CoilData], int]):
async def async_write_coil(self, coil: Coil, value: float | str) -> None:
"""Write coil and update state."""
data = CoilData(coil, value)
await self.connection.write_coil(data)
try:
await self.connection.write_coil(data)
except WriteDeniedException as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="write_denied",
translation_placeholders={
"address": str(coil.address),
"value": str(value),
},
) from e
except WriteTimeoutException as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="write_timeout",
translation_placeholders={
"address": str(coil.address),
},
) from e
except WriteException as e:
LOGGER.debug("Failed to write", exc_info=True)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="write_failed",
translation_placeholders={
"address": str(coil.address),
"value": str(value),
"error": str(e),
},
) from e
self.data[coil.address] = data

View File

@ -45,5 +45,16 @@
"unknown": "[%key:common::config_flow::error::unknown%]",
"url": "The specified URL is not well formed nor supported"
}
},
"exceptions": {
"write_denied": {
"message": "Writing of coil {address} with value `{value}` was denied"
},
"write_timeout": {
"message": "Timeout while writing coil {address}"
},
"write_failed": {
"message": "Writing of coil {address} with value `{value}` failed with error `{error}`"
}
}
}

View File

@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/openai_conversation",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["openai==1.76.2"]
"requirements": ["openai==1.93.0"]
}

View File

@ -115,6 +115,16 @@ class RestData:
for key, value in rendered_params.items():
if isinstance(value, bool):
rendered_params[key] = str(value).lower()
elif not isinstance(value, (str, int, float, type(None))):
# For backward compatibility with httpx behavior, convert non-primitive
# types to strings. This maintains compatibility after switching from
# httpx to aiohttp. See https://github.com/home-assistant/core/issues/148153
_LOGGER.debug(
"REST query parameter '%s' has type %s, converting to string",
key,
type(value).__name__,
)
rendered_params[key] = str(value)
_LOGGER.debug("Updating from %s", self._resource)
# Create request kwargs
@ -140,7 +150,14 @@ class RestData:
self._method, self._resource, **request_kwargs
) as response:
# Read the response
self.data = await response.text(encoding=self._encoding)
# Only use configured encoding if no charset in Content-Type header
# If charset is present in Content-Type, let aiohttp use it
if response.charset:
# Let aiohttp use the charset from Content-Type header
self.data = await response.text()
else:
# Use configured encoding as fallback
self.data = await response.text(encoding=self._encoding)
self.headers = response.headers
except TimeoutError as ex:

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/sharkiq",
"iot_class": "cloud_polling",
"loggers": ["sharkiq"],
"requirements": ["sharkiq==1.1.0"]
"requirements": ["sharkiq==1.1.1"]
}

View File

@ -41,5 +41,5 @@
"iot_class": "local_push",
"loggers": ["switchbot"],
"quality_scale": "gold",
"requirements": ["PySwitchbot==0.67.0"]
"requirements": ["PySwitchbot==0.68.1"]
}

View File

@ -26,7 +26,10 @@ from homeassistant.helpers import (
device_registry as dr,
entity_registry as er,
)
from homeassistant.helpers.service import async_extract_referenced_entity_ids
from homeassistant.helpers.target import (
TargetSelectorData,
async_extract_referenced_entity_ids,
)
from homeassistant.util.json import JsonValueType
from homeassistant.util.read_only_dict import ReadOnlyDict
@ -115,7 +118,7 @@ def _async_get_ufp_instance(hass: HomeAssistant, device_id: str) -> ProtectApiCl
@callback
def _async_get_ufp_camera(call: ServiceCall) -> Camera:
ref = async_extract_referenced_entity_ids(call.hass, call)
ref = async_extract_referenced_entity_ids(call.hass, TargetSelectorData(call.data))
entity_registry = er.async_get(call.hass)
entity_id = ref.indirectly_referenced.pop()
@ -133,7 +136,7 @@ def _async_get_protect_from_call(call: ServiceCall) -> set[ProtectApiClient]:
return {
_async_get_ufp_instance(call.hass, device_id)
for device_id in async_extract_referenced_entity_ids(
call.hass, call
call.hass, TargetSelectorData(call.data)
).referenced_devices
}
@ -196,7 +199,7 @@ def _async_unique_id_to_mac(unique_id: str) -> str:
async def set_chime_paired_doorbells(call: ServiceCall) -> None:
"""Set paired doorbells on chime."""
ref = async_extract_referenced_entity_ids(call.hass, call)
ref = async_extract_referenced_entity_ids(call.hass, TargetSelectorData(call.data))
entity_registry = er.async_get(call.hass)
entity_id = ref.indirectly_referenced.pop()
@ -211,7 +214,9 @@ async def set_chime_paired_doorbells(call: ServiceCall) -> None:
assert chime is not None
call.data = ReadOnlyDict(call.data.get("doorbells") or {})
doorbell_refs = async_extract_referenced_entity_ids(call.hass, call)
doorbell_refs = async_extract_referenced_entity_ids(
call.hass, TargetSelectorData(call.data)
)
doorbell_ids: set[str] = set()
for camera_id in doorbell_refs.referenced | doorbell_refs.indirectly_referenced:
doorbell_sensor = entity_registry.async_get(camera_id)

View File

@ -321,16 +321,18 @@ class StateVacuumEntity(
Integrations should implement a sensor instead.
"""
report_usage(
f"is setting the {property} which has been deprecated."
f" Integration {self.platform.platform_name} should implement a sensor"
" instead with a correct device class and link it to the same device",
core_integration_behavior=ReportBehavior.LOG,
custom_integration_behavior=ReportBehavior.LOG,
breaks_in_ha_version="2026.8",
integration_domain=self.platform.platform_name if self.platform else None,
exclude_integrations={DOMAIN},
)
if self.platform:
# Don't report usage until after entity added to hass, after init
report_usage(
f"is setting the {property} which has been deprecated."
f" Integration {self.platform.platform_name} should implement a sensor"
" instead with a correct device class and link it to the same device",
core_integration_behavior=ReportBehavior.LOG,
custom_integration_behavior=ReportBehavior.LOG,
breaks_in_ha_version="2026.8",
integration_domain=self.platform.platform_name,
exclude_integrations={DOMAIN},
)
@callback
def _report_deprecated_battery_feature(self) -> None:
@ -339,17 +341,19 @@ class StateVacuumEntity(
Integrations should remove the battery supported feature when migrating
battery level and icon to a sensor.
"""
report_usage(
f"is setting the battery supported feature which has been deprecated."
f" Integration {self.platform.platform_name} should remove this as part of migrating"
" the battery level and icon to a sensor",
core_behavior=ReportBehavior.LOG,
core_integration_behavior=ReportBehavior.LOG,
custom_integration_behavior=ReportBehavior.LOG,
breaks_in_ha_version="2026.8",
integration_domain=self.platform.platform_name if self.platform else None,
exclude_integrations={DOMAIN},
)
if self.platform:
# Don't report usage until after entity added to hass, after init
report_usage(
f"is setting the battery supported feature which has been deprecated."
f" Integration {self.platform.platform_name} should remove this as part of migrating"
" the battery level and icon to a sensor",
core_behavior=ReportBehavior.LOG,
core_integration_behavior=ReportBehavior.LOG,
custom_integration_behavior=ReportBehavior.LOG,
breaks_in_ha_version="2026.8",
integration_domain=self.platform.platform_name,
exclude_integrations={DOMAIN},
)
@cached_property
def battery_level(self) -> int | None:

View File

@ -9,7 +9,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from .const import DOMAIN, UPDATE_INTERVAL
from .const import UPDATE_INTERVAL
from .coordinator import InvalidAuth, WallboxCoordinator, async_validate_input
PLATFORMS = [
@ -20,8 +20,10 @@ PLATFORMS = [
Platform.SWITCH,
]
type WallboxConfigEntry = ConfigEntry[WallboxCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: WallboxConfigEntry) -> bool:
"""Set up Wallbox from a config entry."""
wallbox = Wallbox(
entry.data[CONF_USERNAME],
@ -36,7 +38,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
wallbox_coordinator = WallboxCoordinator(hass, entry, wallbox)
await wallbox_coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = wallbox_coordinator
entry.runtime_data = wallbox_coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@ -45,8 +47,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@ -222,7 +222,9 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]):
return data # noqa: TRY300
except requests.exceptions.HTTPError as wallbox_connection_error:
if wallbox_connection_error.response.status_code == 403:
raise InvalidAuth from wallbox_connection_error
raise InvalidAuth(
translation_domain=DOMAIN, translation_key="invalid_auth"
) from wallbox_connection_error
if wallbox_connection_error.response.status_code == 429:
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="too_many_requests"
@ -248,7 +250,9 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]):
return data # noqa: TRY300
except requests.exceptions.HTTPError as wallbox_connection_error:
if wallbox_connection_error.response.status_code == 403:
raise InvalidAuth from wallbox_connection_error
raise InvalidAuth(
translation_domain=DOMAIN, translation_key="invalid_auth"
) from wallbox_connection_error
if wallbox_connection_error.response.status_code == 429:
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="too_many_requests"
@ -303,7 +307,9 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]):
return data # noqa: TRY300
except requests.exceptions.HTTPError as wallbox_connection_error:
if wallbox_connection_error.response.status_code == 403:
raise InvalidAuth from wallbox_connection_error
raise InvalidAuth(
translation_domain=DOMAIN, translation_key="invalid_auth"
) from wallbox_connection_error
if wallbox_connection_error.response.status_code == 429:
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="too_many_requests"

View File

@ -13,7 +13,6 @@ from .const import (
CHARGER_DATA_KEY,
CHARGER_LOCKED_UNLOCKED_KEY,
CHARGER_SERIAL_NUMBER_KEY,
DOMAIN,
)
from .coordinator import WallboxCoordinator
from .entity import WallboxEntity
@ -32,7 +31,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create wallbox lock entities in HASS."""
coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator: WallboxCoordinator = entry.runtime_data
async_add_entities(
WallboxLock(coordinator, description)
for ent in coordinator.data
@ -40,6 +39,10 @@ async def async_setup_entry(
)
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
class WallboxLock(WallboxEntity, LockEntity):
"""Representation of a wallbox lock."""

View File

@ -23,7 +23,6 @@ from .const import (
CHARGER_MAX_ICP_CURRENT_KEY,
CHARGER_PART_NUMBER_KEY,
CHARGER_SERIAL_NUMBER_KEY,
DOMAIN,
)
from .coordinator import WallboxCoordinator
from .entity import WallboxEntity
@ -84,7 +83,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create wallbox number entities in HASS."""
coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator: WallboxCoordinator = entry.runtime_data
async_add_entities(
WallboxNumber(coordinator, entry, description)
for ent in coordinator.data
@ -92,6 +91,10 @@ async def async_setup_entry(
)
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
class WallboxNumber(WallboxEntity, NumberEntity):
"""Representation of the Wallbox portal."""

View File

@ -62,7 +62,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create wallbox select entities in HASS."""
coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator: WallboxCoordinator = entry.runtime_data
if coordinator.data[CHARGER_ECO_SMART_KEY] != EcoSmartMode.DISABLED:
async_add_entities(
WallboxSelect(coordinator, description)
@ -74,6 +74,10 @@ async def async_setup_entry(
)
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
class WallboxSelect(WallboxEntity, SelectEntity):
"""Representation of the Wallbox portal."""

View File

@ -43,7 +43,6 @@ from .const import (
CHARGER_SERIAL_NUMBER_KEY,
CHARGER_STATE_OF_CHARGE_KEY,
CHARGER_STATUS_DESCRIPTION_KEY,
DOMAIN,
)
from .coordinator import WallboxCoordinator
from .entity import WallboxEntity
@ -174,7 +173,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create wallbox sensor entities in HASS."""
coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator: WallboxCoordinator = entry.runtime_data
async_add_entities(
WallboxSensor(coordinator, description)
@ -183,6 +182,10 @@ async def async_setup_entry(
)
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
class WallboxSensor(WallboxEntity, SensorEntity):
"""Representation of the Wallbox portal."""

View File

@ -3,9 +3,14 @@
"step": {
"user": {
"data": {
"station": "Station Serial Number",
"station": "Station serial number",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"station": "Serial number of the charger. Can be found in the Wallbox app or in the Wallbox portal.",
"username": "Username for your Wallbox account.",
"password": "Password for your Wallbox account."
}
},
"reauth_confirm": {
@ -19,7 +24,7 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
"reauth_invalid": "Re-authentication failed; Serial Number does not match original"
"reauth_invalid": "Re-authentication failed; serial number does not match original"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
@ -115,6 +120,9 @@
},
"too_many_requests": {
"message": "Error communicating with Wallbox API, too many requests"
},
"invalid_auth": {
"message": "Invalid authentication"
}
}
}

View File

@ -14,7 +14,6 @@ from .const import (
CHARGER_PAUSE_RESUME_KEY,
CHARGER_SERIAL_NUMBER_KEY,
CHARGER_STATUS_DESCRIPTION_KEY,
DOMAIN,
ChargerStatus,
)
from .coordinator import WallboxCoordinator
@ -34,12 +33,16 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create wallbox sensor entities in HASS."""
coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator: WallboxCoordinator = entry.runtime_data
async_add_entities(
[WallboxSwitch(coordinator, SWITCH_TYPES[CHARGER_PAUSE_RESUME_KEY])]
)
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
class WallboxSwitch(WallboxEntity, SwitchEntity):
"""Representation of the Wallbox portal."""

View File

@ -98,7 +98,10 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
data = {CONF_HOST: self._host, CONF_CLIENT_SECRET: client.client_key}
if not self._name:
self._name = f"{DEFAULT_NAME} {client.tv_info.system['modelName']}"
if model_name := client.tv_info.system.get("modelName"):
self._name = f"{DEFAULT_NAME} {model_name}"
else:
self._name = DEFAULT_NAME
return self.async_create_entry(title=self._name, data=data)
return self.async_show_form(step_id="pairing", errors=errors)

View File

@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/webostv",
"iot_class": "local_push",
"loggers": ["aiowebostv"],
"requirements": ["aiowebostv==0.7.3"],
"requirements": ["aiowebostv==0.7.4"],
"ssdp": [
{
"st": "urn:lge-com:service:webos-second-screen:1"

View File

@ -28,7 +28,7 @@
"step": {
"init": {
"data": {
"keep_master_light": "Keep main light, even with 1 LED segment."
"keep_master_light": "Add 'Main' control even with single LED segment"
}
}
}

View File

@ -0,0 +1,156 @@
"""Offer zone automation rules."""
from __future__ import annotations
import voluptuous as vol
from homeassistant.const import (
ATTR_GPS_ACCURACY,
ATTR_LATITUDE,
ATTR_LONGITUDE,
CONF_CONDITION,
CONF_ENTITY_ID,
CONF_ZONE,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant, State
from homeassistant.exceptions import ConditionErrorContainer, ConditionErrorMessage
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.condition import (
Condition,
ConditionCheckerType,
trace_condition_function,
)
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
from . import in_zone
_CONDITION_SCHEMA = vol.Schema(
{
**cv.CONDITION_BASE_SCHEMA,
vol.Required(CONF_CONDITION): "zone",
vol.Required(CONF_ENTITY_ID): cv.entity_ids,
vol.Required("zone"): cv.entity_ids,
# To support use_trigger_value in automation
# Deprecated 2016/04/25
vol.Optional("event"): vol.Any("enter", "leave"),
}
)
def zone(
hass: HomeAssistant,
zone_ent: str | State | None,
entity: str | State | None,
) -> bool:
"""Test if zone-condition matches.
Async friendly.
"""
if zone_ent is None:
raise ConditionErrorMessage("zone", "no zone specified")
if isinstance(zone_ent, str):
zone_ent_id = zone_ent
if (zone_ent := hass.states.get(zone_ent)) is None:
raise ConditionErrorMessage("zone", f"unknown zone {zone_ent_id}")
if entity is None:
raise ConditionErrorMessage("zone", "no entity specified")
if isinstance(entity, str):
entity_id = entity
if (entity := hass.states.get(entity)) is None:
raise ConditionErrorMessage("zone", f"unknown entity {entity_id}")
else:
entity_id = entity.entity_id
if entity.state in (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
):
return False
latitude = entity.attributes.get(ATTR_LATITUDE)
longitude = entity.attributes.get(ATTR_LONGITUDE)
if latitude is None:
raise ConditionErrorMessage(
"zone", f"entity {entity_id} has no 'latitude' attribute"
)
if longitude is None:
raise ConditionErrorMessage(
"zone", f"entity {entity_id} has no 'longitude' attribute"
)
return in_zone(
zone_ent, latitude, longitude, entity.attributes.get(ATTR_GPS_ACCURACY, 0)
)
class ZoneCondition(Condition):
"""Zone condition."""
def __init__(self, hass: HomeAssistant, config: ConfigType) -> None:
"""Initialize condition."""
self._config = config
@classmethod
async def async_validate_condition_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return _CONDITION_SCHEMA(config) # type: ignore[no-any-return]
async def async_condition_from_config(self) -> ConditionCheckerType:
"""Wrap action method with zone based condition."""
entity_ids = self._config.get(CONF_ENTITY_ID, [])
zone_entity_ids = self._config.get(CONF_ZONE, [])
@trace_condition_function
def if_in_zone(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool:
"""Test if condition."""
errors = []
all_ok = True
for entity_id in entity_ids:
entity_ok = False
for zone_entity_id in zone_entity_ids:
try:
if zone(hass, zone_entity_id, entity_id):
entity_ok = True
except ConditionErrorMessage as ex:
errors.append(
ConditionErrorMessage(
"zone",
(
f"error matching {entity_id} with {zone_entity_id}:"
f" {ex.message}"
),
)
)
if not entity_ok:
all_ok = False
# Raise the errors only if no definitive result was found
if errors and not all_ok:
raise ConditionErrorContainer("zone", errors=errors)
return all_ok
return if_in_zone
CONDITIONS: dict[str, type[Condition]] = {
"zone": ZoneCondition,
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the sun conditions."""
return CONDITIONS

View File

@ -22,7 +22,6 @@ from homeassistant.core import (
callback,
)
from homeassistant.helpers import (
condition,
config_validation as cv,
entity_registry as er,
location,
@ -31,6 +30,8 @@ from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.typing import ConfigType
from . import condition
EVENT_ENTER = "enter"
EVENT_LEAVE = "leave"
DEFAULT_EVENT = EVENT_ENTER

View File

@ -18,9 +18,6 @@ import voluptuous as vol
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_GPS_ACCURACY,
ATTR_LATITUDE,
ATTR_LONGITUDE,
CONF_ABOVE,
CONF_AFTER,
CONF_ATTRIBUTE,
@ -36,7 +33,6 @@ from homeassistant.const import (
CONF_STATE,
CONF_VALUE_TEMPLATE,
CONF_WEEKDAY,
CONF_ZONE,
ENTITY_MATCH_ALL,
ENTITY_MATCH_ANY,
STATE_UNAVAILABLE,
@ -95,7 +91,6 @@ _PLATFORM_ALIASES: dict[str | None, str | None] = {
"template": None,
"time": None,
"trigger": None,
"zone": None,
}
INPUT_ENTITY_ID = re.compile(
@ -919,101 +914,6 @@ def time_from_config(config: ConfigType) -> ConditionCheckerType:
return time_if
def zone(
hass: HomeAssistant,
zone_ent: str | State | None,
entity: str | State | None,
) -> bool:
"""Test if zone-condition matches.
Async friendly.
"""
from homeassistant.components import zone as zone_cmp # noqa: PLC0415
if zone_ent is None:
raise ConditionErrorMessage("zone", "no zone specified")
if isinstance(zone_ent, str):
zone_ent_id = zone_ent
if (zone_ent := hass.states.get(zone_ent)) is None:
raise ConditionErrorMessage("zone", f"unknown zone {zone_ent_id}")
if entity is None:
raise ConditionErrorMessage("zone", "no entity specified")
if isinstance(entity, str):
entity_id = entity
if (entity := hass.states.get(entity)) is None:
raise ConditionErrorMessage("zone", f"unknown entity {entity_id}")
else:
entity_id = entity.entity_id
if entity.state in (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
):
return False
latitude = entity.attributes.get(ATTR_LATITUDE)
longitude = entity.attributes.get(ATTR_LONGITUDE)
if latitude is None:
raise ConditionErrorMessage(
"zone", f"entity {entity_id} has no 'latitude' attribute"
)
if longitude is None:
raise ConditionErrorMessage(
"zone", f"entity {entity_id} has no 'longitude' attribute"
)
return zone_cmp.in_zone(
zone_ent, latitude, longitude, entity.attributes.get(ATTR_GPS_ACCURACY, 0)
)
def zone_from_config(config: ConfigType) -> ConditionCheckerType:
"""Wrap action method with zone based condition."""
entity_ids = config.get(CONF_ENTITY_ID, [])
zone_entity_ids = config.get(CONF_ZONE, [])
@trace_condition_function
def if_in_zone(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool:
"""Test if condition."""
errors = []
all_ok = True
for entity_id in entity_ids:
entity_ok = False
for zone_entity_id in zone_entity_ids:
try:
if zone(hass, zone_entity_id, entity_id):
entity_ok = True
except ConditionErrorMessage as ex:
errors.append(
ConditionErrorMessage(
"zone",
(
f"error matching {entity_id} with {zone_entity_id}:"
f" {ex.message}"
),
)
)
if not entity_ok:
all_ok = False
# Raise the errors only if no definitive result was found
if errors and not all_ok:
raise ConditionErrorContainer("zone", errors=errors)
return all_ok
return if_in_zone
async def async_trigger_from_config(
hass: HomeAssistant, config: ConfigType
) -> ConditionCheckerType:

View File

@ -1570,18 +1570,6 @@ TRIGGER_CONDITION_SCHEMA = vol.Schema(
}
)
ZONE_CONDITION_SCHEMA = vol.Schema(
{
**CONDITION_BASE_SCHEMA,
vol.Required(CONF_CONDITION): "zone",
vol.Required(CONF_ENTITY_ID): entity_ids,
vol.Required("zone"): entity_ids,
# To support use_trigger_value in automation
# Deprecated 2016/04/25
vol.Optional("event"): vol.Any("enter", "leave"),
}
)
AND_CONDITION_SCHEMA = vol.Schema(
{
**CONDITION_BASE_SCHEMA,
@ -1729,7 +1717,6 @@ BUILT_IN_CONDITIONS: ValueSchemas = {
"template": TEMPLATE_CONDITION_SCHEMA,
"time": TIME_CONDITION_SCHEMA,
"trigger": TRIGGER_CONDITION_SCHEMA,
"zone": ZONE_CONDITION_SCHEMA,
}

View File

@ -9,17 +9,13 @@ from enum import Enum
from functools import cache, partial
import logging
from types import ModuleType
from typing import TYPE_CHECKING, Any, TypedDict, TypeGuard, cast
from typing import TYPE_CHECKING, Any, TypedDict, cast, override
import voluptuous as vol
from homeassistant.auth.permissions.const import CAT_ENTITIES, POLICY_CONTROL
from homeassistant.const import (
ATTR_AREA_ID,
ATTR_DEVICE_ID,
ATTR_ENTITY_ID,
ATTR_FLOOR_ID,
ATTR_LABEL_ID,
CONF_ACTION,
CONF_ENTITY_ID,
CONF_SERVICE_DATA,
@ -54,16 +50,14 @@ from homeassistant.util.yaml import load_yaml_dict
from homeassistant.util.yaml.loader import JSON_TYPE
from . import (
area_registry,
config_validation as cv,
device_registry,
entity_registry,
floor_registry,
label_registry,
target as target_helpers,
template,
translation,
)
from .group import expand_entity_ids
from .deprecation import deprecated_class, deprecated_function
from .selector import TargetSelector
from .typing import ConfigType, TemplateVarsType, VolDictType, VolSchemaType
@ -225,87 +219,31 @@ class ServiceParams(TypedDict):
target: dict | None
class ServiceTargetSelector:
@deprecated_class(
"homeassistant.helpers.target.TargetSelectorData",
breaks_in_ha_version="2026.8",
)
class ServiceTargetSelector(target_helpers.TargetSelectorData):
"""Class to hold a target selector for a service."""
__slots__ = ("area_ids", "device_ids", "entity_ids", "floor_ids", "label_ids")
def __init__(self, service_call: ServiceCall) -> None:
"""Extract ids from service call data."""
service_call_data = service_call.data
entity_ids: str | list | None = service_call_data.get(ATTR_ENTITY_ID)
device_ids: str | list | None = service_call_data.get(ATTR_DEVICE_ID)
area_ids: str | list | None = service_call_data.get(ATTR_AREA_ID)
floor_ids: str | list | None = service_call_data.get(ATTR_FLOOR_ID)
label_ids: str | list | None = service_call_data.get(ATTR_LABEL_ID)
self.entity_ids = (
set(cv.ensure_list(entity_ids)) if _has_match(entity_ids) else set()
)
self.device_ids = (
set(cv.ensure_list(device_ids)) if _has_match(device_ids) else set()
)
self.area_ids = set(cv.ensure_list(area_ids)) if _has_match(area_ids) else set()
self.floor_ids = (
set(cv.ensure_list(floor_ids)) if _has_match(floor_ids) else set()
)
self.label_ids = (
set(cv.ensure_list(label_ids)) if _has_match(label_ids) else set()
)
@property
def has_any_selector(self) -> bool:
"""Determine if any selectors are present."""
return bool(
self.entity_ids
or self.device_ids
or self.area_ids
or self.floor_ids
or self.label_ids
)
super().__init__(service_call.data)
@dataclasses.dataclass(slots=True)
class SelectedEntities:
@deprecated_class(
"homeassistant.helpers.target.SelectedEntities",
breaks_in_ha_version="2026.8",
)
class SelectedEntities(target_helpers.SelectedEntities):
"""Class to hold the selected entities."""
# Entities that were explicitly mentioned.
referenced: set[str] = dataclasses.field(default_factory=set)
# Entities that were referenced via device/area/floor/label ID.
# Should not trigger a warning when they don't exist.
indirectly_referenced: set[str] = dataclasses.field(default_factory=set)
# Referenced items that could not be found.
missing_devices: set[str] = dataclasses.field(default_factory=set)
missing_areas: set[str] = dataclasses.field(default_factory=set)
missing_floors: set[str] = dataclasses.field(default_factory=set)
missing_labels: set[str] = dataclasses.field(default_factory=set)
# Referenced devices
referenced_devices: set[str] = dataclasses.field(default_factory=set)
referenced_areas: set[str] = dataclasses.field(default_factory=set)
def log_missing(self, missing_entities: set[str]) -> None:
@override
def log_missing(
self, missing_entities: set[str], logger: logging.Logger | None = None
) -> None:
"""Log about missing items."""
parts = []
for label, items in (
("floors", self.missing_floors),
("areas", self.missing_areas),
("devices", self.missing_devices),
("entities", missing_entities),
("labels", self.missing_labels),
):
if items:
parts.append(f"{label} {', '.join(sorted(items))}")
if not parts:
return
_LOGGER.warning(
"Referenced %s are missing or not currently available",
", ".join(parts),
)
super().log_missing(missing_entities, logger or _LOGGER)
@bind_hass
@ -466,7 +404,10 @@ async def async_extract_entities[_EntityT: Entity](
if data_ent_id == ENTITY_MATCH_ALL:
return [entity for entity in entities if entity.available]
referenced = async_extract_referenced_entity_ids(hass, service_call, expand_group)
selector_data = target_helpers.TargetSelectorData(service_call.data)
referenced = target_helpers.async_extract_referenced_entity_ids(
hass, selector_data, expand_group
)
combined = referenced.referenced | referenced.indirectly_referenced
found = []
@ -482,7 +423,7 @@ async def async_extract_entities[_EntityT: Entity](
found.append(entity)
referenced.log_missing(referenced.referenced & combined)
referenced.log_missing(referenced.referenced & combined, _LOGGER)
return found
@ -495,141 +436,27 @@ async def async_extract_entity_ids(
Will convert group entity ids to the entity ids it represents.
"""
referenced = async_extract_referenced_entity_ids(hass, service_call, expand_group)
selector_data = target_helpers.TargetSelectorData(service_call.data)
referenced = target_helpers.async_extract_referenced_entity_ids(
hass, selector_data, expand_group
)
return referenced.referenced | referenced.indirectly_referenced
def _has_match(ids: str | list[str] | None) -> TypeGuard[str | list[str]]:
"""Check if ids can match anything."""
return ids not in (None, ENTITY_MATCH_NONE)
@deprecated_function(
"homeassistant.helpers.target.async_extract_referenced_entity_ids",
breaks_in_ha_version="2026.8",
)
@bind_hass
def async_extract_referenced_entity_ids(
hass: HomeAssistant, service_call: ServiceCall, expand_group: bool = True
) -> SelectedEntities:
"""Extract referenced entity IDs from a service call."""
selector = ServiceTargetSelector(service_call)
selected = SelectedEntities()
if not selector.has_any_selector:
return selected
entity_ids: set[str] | list[str] = selector.entity_ids
if expand_group:
entity_ids = expand_entity_ids(hass, entity_ids)
selected.referenced.update(entity_ids)
if (
not selector.device_ids
and not selector.area_ids
and not selector.floor_ids
and not selector.label_ids
):
return selected
entities = entity_registry.async_get(hass).entities
dev_reg = device_registry.async_get(hass)
area_reg = area_registry.async_get(hass)
if selector.floor_ids:
floor_reg = floor_registry.async_get(hass)
for floor_id in selector.floor_ids:
if floor_id not in floor_reg.floors:
selected.missing_floors.add(floor_id)
for area_id in selector.area_ids:
if area_id not in area_reg.areas:
selected.missing_areas.add(area_id)
for device_id in selector.device_ids:
if device_id not in dev_reg.devices:
selected.missing_devices.add(device_id)
if selector.label_ids:
label_reg = label_registry.async_get(hass)
for label_id in selector.label_ids:
if label_id not in label_reg.labels:
selected.missing_labels.add(label_id)
for entity_entry in entities.get_entries_for_label(label_id):
if (
entity_entry.entity_category is None
and entity_entry.hidden_by is None
):
selected.indirectly_referenced.add(entity_entry.entity_id)
for device_entry in dev_reg.devices.get_devices_for_label(label_id):
selected.referenced_devices.add(device_entry.id)
for area_entry in area_reg.areas.get_areas_for_label(label_id):
selected.referenced_areas.add(area_entry.id)
# Find areas for targeted floors
if selector.floor_ids:
selected.referenced_areas.update(
area_entry.id
for floor_id in selector.floor_ids
for area_entry in area_reg.areas.get_areas_for_floor(floor_id)
)
selected.referenced_areas.update(selector.area_ids)
selected.referenced_devices.update(selector.device_ids)
if not selected.referenced_areas and not selected.referenced_devices:
return selected
# Add indirectly referenced by device
selected.indirectly_referenced.update(
entry.entity_id
for device_id in selected.referenced_devices
for entry in entities.get_entries_for_device_id(device_id)
# Do not add entities which are hidden or which are config
# or diagnostic entities.
if (entry.entity_category is None and entry.hidden_by is None)
selector_data = target_helpers.TargetSelectorData(service_call.data)
selected = target_helpers.async_extract_referenced_entity_ids(
hass, selector_data, expand_group
)
# Find devices for targeted areas
referenced_devices_by_area: set[str] = set()
if selected.referenced_areas:
for area_id in selected.referenced_areas:
referenced_devices_by_area.update(
device_entry.id
for device_entry in dev_reg.devices.get_devices_for_area_id(area_id)
)
selected.referenced_devices.update(referenced_devices_by_area)
# Add indirectly referenced by area
selected.indirectly_referenced.update(
entry.entity_id
for area_id in selected.referenced_areas
# The entity's area matches a targeted area
for entry in entities.get_entries_for_area_id(area_id)
# Do not add entities which are hidden or which are config
# or diagnostic entities.
if entry.entity_category is None and entry.hidden_by is None
)
# Add indirectly referenced by area through device
selected.indirectly_referenced.update(
entry.entity_id
for device_id in referenced_devices_by_area
for entry in entities.get_entries_for_device_id(device_id)
# Do not add entities which are hidden or which are config
# or diagnostic entities.
if (
entry.entity_category is None
and entry.hidden_by is None
and (
# The entity's device matches a device referenced
# by an area and the entity
# has no explicitly set area
not entry.area_id
)
)
)
return selected
return SelectedEntities(**dataclasses.asdict(selected))
@bind_hass
@ -637,7 +464,10 @@ async def async_extract_config_entry_ids(
hass: HomeAssistant, service_call: ServiceCall, expand_group: bool = True
) -> set[str]:
"""Extract referenced config entry ids from a service call."""
referenced = async_extract_referenced_entity_ids(hass, service_call, expand_group)
selector_data = target_helpers.TargetSelectorData(service_call.data)
referenced = target_helpers.async_extract_referenced_entity_ids(
hass, selector_data, expand_group
)
ent_reg = entity_registry.async_get(hass)
dev_reg = device_registry.async_get(hass)
config_entry_ids: set[str] = set()
@ -948,11 +778,14 @@ async def entity_service_call(
target_all_entities = call.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_ALL
if target_all_entities:
referenced: SelectedEntities | None = None
referenced: target_helpers.SelectedEntities | None = None
all_referenced: set[str] | None = None
else:
# A set of entities we're trying to target.
referenced = async_extract_referenced_entity_ids(hass, call, True)
selector_data = target_helpers.TargetSelectorData(call.data)
referenced = target_helpers.async_extract_referenced_entity_ids(
hass, selector_data, True
)
all_referenced = referenced.referenced | referenced.indirectly_referenced
# If the service function is a string, we'll pass it the service call data
@ -977,7 +810,7 @@ async def entity_service_call(
missing = referenced.referenced.copy()
for entity in entity_candidates:
missing.discard(entity.entity_id)
referenced.log_missing(missing)
referenced.log_missing(missing, _LOGGER)
entities: list[Entity] = []
for entity in entity_candidates:

View File

@ -0,0 +1,240 @@
"""Helpers for dealing with entity targets."""
from __future__ import annotations
import dataclasses
from logging import Logger
from typing import TypeGuard
from homeassistant.const import (
ATTR_AREA_ID,
ATTR_DEVICE_ID,
ATTR_ENTITY_ID,
ATTR_FLOOR_ID,
ATTR_LABEL_ID,
ENTITY_MATCH_NONE,
)
from homeassistant.core import HomeAssistant
from . import (
area_registry as ar,
config_validation as cv,
device_registry as dr,
entity_registry as er,
floor_registry as fr,
group,
label_registry as lr,
)
from .typing import ConfigType
def _has_match(ids: str | list[str] | None) -> TypeGuard[str | list[str]]:
"""Check if ids can match anything."""
return ids not in (None, ENTITY_MATCH_NONE)
class TargetSelectorData:
"""Class to hold data of target selector."""
__slots__ = ("area_ids", "device_ids", "entity_ids", "floor_ids", "label_ids")
def __init__(self, config: ConfigType) -> None:
"""Extract ids from the config."""
entity_ids: str | list | None = config.get(ATTR_ENTITY_ID)
device_ids: str | list | None = config.get(ATTR_DEVICE_ID)
area_ids: str | list | None = config.get(ATTR_AREA_ID)
floor_ids: str | list | None = config.get(ATTR_FLOOR_ID)
label_ids: str | list | None = config.get(ATTR_LABEL_ID)
self.entity_ids = (
set(cv.ensure_list(entity_ids)) if _has_match(entity_ids) else set()
)
self.device_ids = (
set(cv.ensure_list(device_ids)) if _has_match(device_ids) else set()
)
self.area_ids = set(cv.ensure_list(area_ids)) if _has_match(area_ids) else set()
self.floor_ids = (
set(cv.ensure_list(floor_ids)) if _has_match(floor_ids) else set()
)
self.label_ids = (
set(cv.ensure_list(label_ids)) if _has_match(label_ids) else set()
)
@property
def has_any_selector(self) -> bool:
"""Determine if any selectors are present."""
return bool(
self.entity_ids
or self.device_ids
or self.area_ids
or self.floor_ids
or self.label_ids
)
@dataclasses.dataclass(slots=True)
class SelectedEntities:
"""Class to hold the selected entities."""
# Entities that were explicitly mentioned.
referenced: set[str] = dataclasses.field(default_factory=set)
# Entities that were referenced via device/area/floor/label ID.
# Should not trigger a warning when they don't exist.
indirectly_referenced: set[str] = dataclasses.field(default_factory=set)
# Referenced items that could not be found.
missing_devices: set[str] = dataclasses.field(default_factory=set)
missing_areas: set[str] = dataclasses.field(default_factory=set)
missing_floors: set[str] = dataclasses.field(default_factory=set)
missing_labels: set[str] = dataclasses.field(default_factory=set)
referenced_devices: set[str] = dataclasses.field(default_factory=set)
referenced_areas: set[str] = dataclasses.field(default_factory=set)
def log_missing(self, missing_entities: set[str], logger: Logger) -> None:
"""Log about missing items."""
parts = []
for label, items in (
("floors", self.missing_floors),
("areas", self.missing_areas),
("devices", self.missing_devices),
("entities", missing_entities),
("labels", self.missing_labels),
):
if items:
parts.append(f"{label} {', '.join(sorted(items))}")
if not parts:
return
logger.warning(
"Referenced %s are missing or not currently available",
", ".join(parts),
)
def async_extract_referenced_entity_ids(
hass: HomeAssistant, selector_data: TargetSelectorData, expand_group: bool = True
) -> SelectedEntities:
"""Extract referenced entity IDs from a target selector."""
selected = SelectedEntities()
if not selector_data.has_any_selector:
return selected
entity_ids: set[str] | list[str] = selector_data.entity_ids
if expand_group:
entity_ids = group.expand_entity_ids(hass, entity_ids)
selected.referenced.update(entity_ids)
if (
not selector_data.device_ids
and not selector_data.area_ids
and not selector_data.floor_ids
and not selector_data.label_ids
):
return selected
entities = er.async_get(hass).entities
dev_reg = dr.async_get(hass)
area_reg = ar.async_get(hass)
if selector_data.floor_ids:
floor_reg = fr.async_get(hass)
for floor_id in selector_data.floor_ids:
if floor_id not in floor_reg.floors:
selected.missing_floors.add(floor_id)
for area_id in selector_data.area_ids:
if area_id not in area_reg.areas:
selected.missing_areas.add(area_id)
for device_id in selector_data.device_ids:
if device_id not in dev_reg.devices:
selected.missing_devices.add(device_id)
if selector_data.label_ids:
label_reg = lr.async_get(hass)
for label_id in selector_data.label_ids:
if label_id not in label_reg.labels:
selected.missing_labels.add(label_id)
for entity_entry in entities.get_entries_for_label(label_id):
if (
entity_entry.entity_category is None
and entity_entry.hidden_by is None
):
selected.indirectly_referenced.add(entity_entry.entity_id)
for device_entry in dev_reg.devices.get_devices_for_label(label_id):
selected.referenced_devices.add(device_entry.id)
for area_entry in area_reg.areas.get_areas_for_label(label_id):
selected.referenced_areas.add(area_entry.id)
# Find areas for targeted floors
if selector_data.floor_ids:
selected.referenced_areas.update(
area_entry.id
for floor_id in selector_data.floor_ids
for area_entry in area_reg.areas.get_areas_for_floor(floor_id)
)
selected.referenced_areas.update(selector_data.area_ids)
selected.referenced_devices.update(selector_data.device_ids)
if not selected.referenced_areas and not selected.referenced_devices:
return selected
# Add indirectly referenced by device
selected.indirectly_referenced.update(
entry.entity_id
for device_id in selected.referenced_devices
for entry in entities.get_entries_for_device_id(device_id)
# Do not add entities which are hidden or which are config
# or diagnostic entities.
if (entry.entity_category is None and entry.hidden_by is None)
)
# Find devices for targeted areas
referenced_devices_by_area: set[str] = set()
if selected.referenced_areas:
for area_id in selected.referenced_areas:
referenced_devices_by_area.update(
device_entry.id
for device_entry in dev_reg.devices.get_devices_for_area_id(area_id)
)
selected.referenced_devices.update(referenced_devices_by_area)
# Add indirectly referenced by area
selected.indirectly_referenced.update(
entry.entity_id
for area_id in selected.referenced_areas
# The entity's area matches a targeted area
for entry in entities.get_entries_for_area_id(area_id)
# Do not add entities which are hidden or which are config
# or diagnostic entities.
if entry.entity_category is None and entry.hidden_by is None
)
# Add indirectly referenced by area through device
selected.indirectly_referenced.update(
entry.entity_id
for device_id in referenced_devices_by_area
for entry in entities.get_entries_for_device_id(device_id)
# Do not add entities which are hidden or which are config
# or diagnostic entities.
if (
entry.entity_category is None
and entry.hidden_by is None
and (
# The entity's device matches a device referenced
# by an area and the entity
# has no explicitly set area
not entry.area_id
)
)
)
return selected

14
requirements_all.txt generated
View File

@ -84,7 +84,7 @@ PyQRCode==1.2.1
PyRMVtransport==0.3.3
# homeassistant.components.switchbot
PySwitchbot==0.67.0
PySwitchbot==0.68.1
# homeassistant.components.switchmate
PySwitchmate==0.5.1
@ -435,7 +435,7 @@ aiowatttime==0.1.1
aiowebdav2==0.4.6
# homeassistant.components.webostv
aiowebostv==0.7.3
aiowebostv==0.7.4
# homeassistant.components.withings
aiowithings==3.1.6
@ -989,7 +989,7 @@ gTTS==2.5.3
gardena-bluetooth==1.6.0
# homeassistant.components.google_assistant_sdk
gassist-text==0.0.12
gassist-text==0.0.14
# homeassistant.components.google
gcal-sync==7.1.0
@ -1020,7 +1020,7 @@ georss-qld-bushfire-alert-client==0.8
getmac==0.9.5
# homeassistant.components.gios
gios==6.0.0
gios==6.1.0
# homeassistant.components.gitter
gitterpy==0.1.7
@ -1597,7 +1597,7 @@ open-garage==0.2.0
open-meteo==0.3.2
# homeassistant.components.openai_conversation
openai==1.76.2
openai==1.93.0
# homeassistant.components.openerz
openerz-api==0.3.0
@ -1962,7 +1962,7 @@ pyeiscp==0.0.7
pyemoncms==0.1.1
# homeassistant.components.enphase_envoy
pyenphase==2.2.0
pyenphase==2.2.1
# homeassistant.components.envisalink
pyenvisalink==4.7
@ -2756,7 +2756,7 @@ sentry-sdk==1.45.1
sfrbox-api==0.0.12
# homeassistant.components.sharkiq
sharkiq==1.1.0
sharkiq==1.1.1
# homeassistant.components.aquostv
sharp_aquos_rc==0.3.2

View File

@ -81,7 +81,7 @@ PyQRCode==1.2.1
PyRMVtransport==0.3.3
# homeassistant.components.switchbot
PySwitchbot==0.67.0
PySwitchbot==0.68.1
# homeassistant.components.syncthru
PySyncThru==0.8.0
@ -417,7 +417,7 @@ aiowatttime==0.1.1
aiowebdav2==0.4.6
# homeassistant.components.webostv
aiowebostv==0.7.3
aiowebostv==0.7.4
# homeassistant.components.withings
aiowithings==3.1.6
@ -859,7 +859,7 @@ gTTS==2.5.3
gardena-bluetooth==1.6.0
# homeassistant.components.google_assistant_sdk
gassist-text==0.0.12
gassist-text==0.0.14
# homeassistant.components.google
gcal-sync==7.1.0
@ -890,7 +890,7 @@ georss-qld-bushfire-alert-client==0.8
getmac==0.9.5
# homeassistant.components.gios
gios==6.0.0
gios==6.1.0
# homeassistant.components.glances
glances-api==0.8.0
@ -1365,7 +1365,7 @@ open-garage==0.2.0
open-meteo==0.3.2
# homeassistant.components.openai_conversation
openai==1.76.2
openai==1.93.0
# homeassistant.components.openerz
openerz-api==0.3.0
@ -1637,7 +1637,7 @@ pyeiscp==0.0.7
pyemoncms==0.1.1
# homeassistant.components.enphase_envoy
pyenphase==2.2.0
pyenphase==2.2.1
# homeassistant.components.everlights
pyeverlights==0.1.0
@ -2278,7 +2278,7 @@ sentry-sdk==1.45.1
sfrbox-api==0.0.12
# homeassistant.components.sharkiq
sharkiq==1.1.0
sharkiq==1.1.1
# homeassistant.components.simplefin
simplefin4py==0.0.18

View File

@ -54,6 +54,7 @@ CONDITIONS_SCHEMA = vol.Schema(
NON_MIGRATED_INTEGRATIONS = {
"device_automation",
"sun",
"zone",
}

View File

@ -1,16 +1,29 @@
"""Tests for GIOS."""
import json
from unittest.mock import patch
from homeassistant.components.gios.const import DOMAIN
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry, async_load_fixture
from tests.common import (
MockConfigEntry,
async_load_json_array_fixture,
async_load_json_object_fixture,
)
STATIONS = [
{"id": 123, "stationName": "Test Name 1", "gegrLat": "99.99", "gegrLon": "88.88"},
{"id": 321, "stationName": "Test Name 2", "gegrLat": "77.77", "gegrLon": "66.66"},
{
"Identyfikator stacji": 123,
"Nazwa stacji": "Test Name 1",
"WGS84 φ N": "99.99",
"WGS84 λ E": "88.88",
},
{
"Identyfikator stacji": 321,
"Nazwa stacji": "Test Name 2",
"WGS84 φ N": "77.77",
"WGS84 λ E": "66.66",
},
]
@ -26,13 +39,13 @@ async def init_integration(
entry_id="86129426118ae32020417a53712d6eef",
)
indexes = json.loads(await async_load_fixture(hass, "indexes.json", DOMAIN))
station = json.loads(await async_load_fixture(hass, "station.json", DOMAIN))
sensors = json.loads(await async_load_fixture(hass, "sensors.json", DOMAIN))
indexes = await async_load_json_object_fixture(hass, "indexes.json", DOMAIN)
station = await async_load_json_array_fixture(hass, "station.json", DOMAIN)
sensors = await async_load_json_object_fixture(hass, "sensors.json", DOMAIN)
if incomplete_data:
indexes["stIndexLevel"]["indexLevelName"] = "foo"
sensors["pm10"]["values"][0]["value"] = None
sensors["pm10"]["values"][1]["value"] = None
indexes["AqIndex"] = "foo"
sensors["pm10"]["Lista danych pomiarowych"][0]["Wartość"] = None
sensors["pm10"]["Lista danych pomiarowych"][1]["Wartość"] = None
if invalid_indexes:
indexes = {}

View File

@ -1,29 +1,38 @@
{
"id": 123,
"stCalcDate": "2020-07-31 15:10:17",
"stIndexLevel": { "id": 1, "indexLevelName": "Dobry" },
"stSourceDataDate": "2020-07-31 14:00:00",
"so2CalcDate": "2020-07-31 15:10:17",
"so2IndexLevel": { "id": 0, "indexLevelName": "Bardzo dobry" },
"so2SourceDataDate": "2020-07-31 14:00:00",
"no2CalcDate": 1596201017000,
"no2IndexLevel": { "id": 0, "indexLevelName": "Dobry" },
"no2SourceDataDate": "2020-07-31 14:00:00",
"coCalcDate": "2020-07-31 15:10:17",
"coIndexLevel": { "id": 0, "indexLevelName": "Dobry" },
"coSourceDataDate": "2020-07-31 14:00:00",
"pm10CalcDate": "2020-07-31 15:10:17",
"pm10IndexLevel": { "id": 0, "indexLevelName": "Dobry" },
"pm10SourceDataDate": "2020-07-31 14:00:00",
"pm25CalcDate": "2020-07-31 15:10:17",
"pm25IndexLevel": { "id": 0, "indexLevelName": "Dobry" },
"pm25SourceDataDate": "2020-07-31 14:00:00",
"o3CalcDate": "2020-07-31 15:10:17",
"o3IndexLevel": { "id": 1, "indexLevelName": "Dobry" },
"o3SourceDataDate": "2020-07-31 14:00:00",
"c6h6CalcDate": "2020-07-31 15:10:17",
"c6h6IndexLevel": { "id": 0, "indexLevelName": "Bardzo dobry" },
"c6h6SourceDataDate": "2020-07-31 14:00:00",
"stIndexStatus": true,
"stIndexCrParam": "OZON"
"AqIndex": {
"Identyfikator stacji pomiarowej": 123,
"Data wykonania obliczeń indeksu": "2020-07-31 15:10:17",
"Nazwa kategorii indeksu": "Dobry",
"Data danych źródłowych, z których policzono wartość indeksu dla wskaźnika st": "2020-07-31 14:00:00",
"Data wykonania obliczeń indeksu dla wskaźnika SO2": "2020-07-31 15:10:17",
"Wartość indeksu dla wskaźnika SO2": 0,
"Nazwa kategorii indeksu dla wskażnika SO2": "Bardzo dobry",
"Data danych źródłowych, z których policzono wartość indeksu dla wskaźnika SO2": "2020-07-31 14:00:00",
"Data wykonania obliczeń indeksu dla wskaźnika NO2": "2020-07-31 14:00:00",
"Wartość indeksu dla wskaźnika NO2": 0,
"Nazwa kategorii indeksu dla wskażnika NO2": "Dobry",
"Data danych źródłowych, z których policzono wartość indeksu dla wskaźnika NO2": "2020-07-31 14:00:00",
"Data danych źródłowych, z których policzono wartość indeksu dla wskaźnika CO": "2020-07-31 15:10:17",
"Wartość indeksu dla wskaźnika CO": 0,
"Nazwa kategorii indeksu dla wskażnika CO": "Dobry",
"Data wykonania obliczeń indeksu dla wskaźnika CO": "2020-07-31 14:00:00",
"Data danych źródłowych, z których policzono wartość indeksu dla wskaźnika PM10": "2020-07-31 15:10:17",
"Wartość indeksu dla wskaźnika PM10": 0,
"Nazwa kategorii indeksu dla wskażnika PM10": "Dobry",
"Data wykonania obliczeń indeksu dla wskaźnika PM10": "2020-07-31 14:00:00",
"Data danych źródłowych, z których policzono wartość indeksu dla wskaźnika PM2.5": "2020-07-31 15:10:17",
"Wartość indeksu dla wskaźnika PM2.5": 0,
"Nazwa kategorii indeksu dla wskażnika PM2.5": "Dobry",
"Data wykonania obliczeń indeksu dla wskaźnika PM2.5": "2020-07-31 14:00:00",
"Data danych źródłowych, z których policzono wartość indeksu dla wskaźnika O3": "2020-07-31 15:10:17",
"Wartość indeksu dla wskaźnika O3": 1,
"Nazwa kategorii indeksu dla wskażnika O3": "Dobry",
"Data wykonania obliczeń indeksu dla wskaźnika O3": "2020-07-31 14:00:00",
"Data danych źródłowych, z których policzono wartość indeksu dla wskaźnika C6H6": "2020-07-31 15:10:17",
"Wartość indeksu dla wskaźnika C6H6": 0,
"Nazwa kategorii indeksu dla wskażnika C6H6": "Bardzo dobry",
"Data wykonania obliczeń indeksu dla wskaźnika C6H6": "2020-07-31 14:00:00",
"Status indeksu ogólnego dla stacji pomiarowej": true,
"Kod zanieczyszczenia krytycznego": "OZON"
}
}

View File

@ -1,51 +1,51 @@
{
"so2": {
"values": [
{ "date": "2020-07-31 15:00:00", "value": 4.35478 },
{ "date": "2020-07-31 14:00:00", "value": 4.25478 },
{ "date": "2020-07-31 13:00:00", "value": 4.34309 }
"Lista danych pomiarowych": [
{ "Data": "2020-07-31 15:00:00", "Wartość": 4.35478 },
{ "Data": "2020-07-31 14:00:00", "Wartość": 4.25478 },
{ "Data": "2020-07-31 13:00:00", "Wartość": 4.34309 }
]
},
"c6h6": {
"values": [
{ "date": "2020-07-31 15:00:00", "value": 0.23789 },
{ "date": "2020-07-31 14:00:00", "value": 0.22789 },
{ "date": "2020-07-31 13:00:00", "value": 0.21315 }
"Lista danych pomiarowych": [
{ "Data": "2020-07-31 15:00:00", "Wartość": 0.23789 },
{ "Data": "2020-07-31 14:00:00", "Wartość": 0.22789 },
{ "Data": "2020-07-31 13:00:00", "Wartość": 0.21315 }
]
},
"co": {
"values": [
{ "date": "2020-07-31 15:00:00", "value": 251.874 },
{ "date": "2020-07-31 14:00:00", "value": 250.874 },
{ "date": "2020-07-31 13:00:00", "value": 251.097 }
"Lista danych pomiarowych": [
{ "Data": "2020-07-31 15:00:00", "Wartość": 251.874 },
{ "Data": "2020-07-31 14:00:00", "Wartość": 250.874 },
{ "Data": "2020-07-31 13:00:00", "Wartość": 251.097 }
]
},
"no2": {
"values": [
{ "date": "2020-07-31 15:00:00", "value": 7.13411 },
{ "date": "2020-07-31 14:00:00", "value": 7.33411 },
{ "date": "2020-07-31 13:00:00", "value": 9.32578 }
"Lista danych pomiarowych": [
{ "Data": "2020-07-31 15:00:00", "Wartość": 7.13411 },
{ "Data": "2020-07-31 14:00:00", "Wartość": 7.33411 },
{ "Data": "2020-07-31 13:00:00", "Wartość": 9.32578 }
]
},
"o3": {
"values": [
{ "date": "2020-07-31 15:00:00", "value": 95.7768 },
{ "date": "2020-07-31 14:00:00", "value": 93.7768 },
{ "date": "2020-07-31 13:00:00", "value": 89.4232 }
"Lista danych pomiarowych": [
{ "Data": "2020-07-31 15:00:00", "Wartość": 95.7768 },
{ "Data": "2020-07-31 14:00:00", "Wartość": 93.7768 },
{ "Data": "2020-07-31 13:00:00", "Wartość": 89.4232 }
]
},
"pm2.5": {
"values": [
{ "date": "2020-07-31 15:00:00", "value": 4 },
{ "date": "2020-07-31 14:00:00", "value": 4 },
{ "date": "2020-07-31 13:00:00", "value": 5 }
"Lista danych pomiarowych": [
{ "Data": "2020-07-31 15:00:00", "Wartość": 4 },
{ "Data": "2020-07-31 14:00:00", "Wartość": 4 },
{ "Data": "2020-07-31 13:00:00", "Wartość": 5 }
]
},
"pm10": {
"values": [
{ "date": "2020-07-31 15:00:00", "value": 16.8344 },
{ "date": "2020-07-31 14:00:00", "value": 17.8344 },
{ "date": "2020-07-31 13:00:00", "value": 20.8094 }
"Lista danych pomiarowych": [
{ "Data": "2020-07-31 15:00:00", "Wartość": 16.8344 },
{ "Data": "2020-07-31 14:00:00", "Wartość": 17.8344 },
{ "Data": "2020-07-31 13:00:00", "Wartość": 20.8094 }
]
}
}

View File

@ -1,72 +1,58 @@
[
{
"id": 672,
"stationId": 117,
"param": {
"paramName": "dwutlenek siarki",
"paramFormula": "SO2",
"paramCode": "SO2",
"idParam": 1
}
"Identyfikator stanowiska": 672,
"Identyfikator stacji": 117,
"Wskaźnik": "dwutlenek siarki",
"Wskaźnik - wzór": "SO2",
"Wskaźnik - kod": "SO2",
"Id wskaźnika": 1
},
{
"id": 658,
"stationId": 117,
"param": {
"paramName": "benzen",
"paramFormula": "C6H6",
"paramCode": "C6H6",
"idParam": 10
}
"Identyfikator stanowiska": 658,
"Identyfikator stacji": 117,
"Wskaźnik": "benzen",
"Wskaźnik - wzór": "C6H6",
"Wskaźnik - kod": "C6H6",
"Id wskaźnika": 10
},
{
"id": 660,
"stationId": 117,
"param": {
"paramName": "tlenek węgla",
"paramFormula": "CO",
"paramCode": "CO",
"idParam": 8
}
"Identyfikator stanowiska": 660,
"Identyfikator stacji": 117,
"Wskaźnik": "tlenek węgla",
"Wskaźnik - wzór": "CO",
"Wskaźnik - kod": "CO",
"Id wskaźnika": 8
},
{
"id": 665,
"stationId": 117,
"param": {
"paramName": "dwutlenek azotu",
"paramFormula": "NO2",
"paramCode": "NO2",
"idParam": 6
}
"Identyfikator stanowiska": 665,
"Identyfikator stacji": 117,
"Wskaźnik": "dwutlenek azotu",
"Wskaźnik - wzór": "NO2",
"Wskaźnik - kod": "NO2",
"Id wskaźnika": 6
},
{
"id": 667,
"stationId": 117,
"param": {
"paramName": "ozon",
"paramFormula": "O3",
"paramCode": "O3",
"idParam": 5
}
"Identyfikator stanowiska": 667,
"Identyfikator stacji": 117,
"Wskaźnik": "ozon",
"Wskaźnik - wzór": "O3",
"Wskaźnik - kod": "O3",
"Id wskaźnika": 5
},
{
"id": 670,
"stationId": 117,
"param": {
"paramName": "pył zawieszony PM2.5",
"paramFormula": "PM2.5",
"paramCode": "PM2.5",
"idParam": 69
}
"Identyfikator stanowiska": 670,
"Identyfikator stacji": 117,
"Wskaźnik": "pył zawieszony PM2.5",
"Wskaźnik - wzór": "PM2.5",
"Wskaźnik - kod": "PM2.5",
"Id wskaźnika": 69
},
{
"id": 14395,
"stationId": 117,
"param": {
"paramName": "pył zawieszony PM10",
"paramFormula": "PM10",
"paramCode": "PM10",
"idParam": 3
}
"Identyfikator stanowiska": 14395,
"Identyfikator stacji": 117,
"Wskaźnik": "pył zawieszony PM10",
"Wskaźnik - wzór": "PM10",
"Wskaźnik - kod": "PM10",
"Id wskaźnika": 3
}
]

View File

@ -42,12 +42,14 @@
'name': 'carbon monoxide',
'value': 251.874,
}),
'no': None,
'no2': dict({
'id': 665,
'index': 'good',
'name': 'nitrogen dioxide',
'value': 7.13411,
}),
'nox': None,
'o3': dict({
'id': 667,
'index': 'good',

View File

@ -18,10 +18,18 @@ from tests.common import MockConfigEntry
@pytest.mark.parametrize(
"side_eff",
("side_eff", "config_entry_state", "active_flows"),
[
HomeeConnectionFailedException("connection timed out"),
HomeeAuthFailedException("wrong username or password"),
(
HomeeConnectionFailedException("connection timed out"),
ConfigEntryState.SETUP_RETRY,
[],
),
(
HomeeAuthFailedException("wrong username or password"),
ConfigEntryState.SETUP_ERROR,
["reauth"],
),
],
)
async def test_connection_errors(
@ -29,6 +37,8 @@ async def test_connection_errors(
mock_homee: MagicMock,
mock_config_entry: MockConfigEntry,
side_eff: Exception,
config_entry_state: ConfigEntryState,
active_flows: list[str],
) -> None:
"""Test if connection errors on startup are handled correctly."""
mock_homee.get_access_token.side_effect = side_eff
@ -36,7 +46,11 @@ async def test_connection_errors(
await hass.config_entries.async_setup(mock_config_entry.entry_id)
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
assert mock_config_entry.state is config_entry_state
assert [
flow["context"]["source"] for flow in hass.config_entries.flow.async_progress()
] == active_flows
async def test_connection_listener(

View File

@ -522,24 +522,6 @@ async def test_logging(
assert "GET /api/states/logging.entity" not in caplog.text
async def test_register_static_paths(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test registering a static path with old api."""
assert await async_setup_component(hass, "frontend", {})
path = str(Path(__file__).parent)
match_error = (
"Detected code that calls hass.http.register_static_path "
"which does blocking I/O in the event loop, instead call "
"`await hass.http.async_register_static_paths"
)
with pytest.raises(RuntimeError, match=match_error):
hass.http.register_static_path("/something", path)
async def test_ssl_issue_if_no_urls_configured(
hass: HomeAssistant,
tmp_path: Path,

View File

@ -685,7 +685,7 @@
'state': 'on',
})
# ---
# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_supply_charging_state-entry]
# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_charger_supply_state-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@ -698,7 +698,7 @@
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.evse_supply_charging_state',
'entity_id': 'binary_sensor.evse_charger_supply_state',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@ -710,24 +710,24 @@
}),
'original_device_class': <BinarySensorDeviceClass.RUNNING: 'running'>,
'original_icon': None,
'original_name': 'Supply charging state',
'original_name': 'Charger supply state',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'evse_supply_charging_state',
'translation_key': 'evse_supply_state',
'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseSupplyStateSensor-153-1',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_supply_charging_state-state]
# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_charger_supply_state-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'running',
'friendly_name': 'evse Supply charging state',
'friendly_name': 'evse Charger supply state',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.evse_supply_charging_state',
'entity_id': 'binary_sensor.evse_charger_supply_state',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,

View File

@ -184,8 +184,8 @@ async def test_evse_sensor(
assert state
assert state.state == "off"
# Test SupplyStateEnum value with binary_sensor.evse_supply_charging
entity_id = "binary_sensor.evse_supply_charging_state"
# Test SupplyStateEnum value with binary_sensor.evse_charger_supply_state
entity_id = "binary_sensor.evse_charger_supply_state"
state = hass.states.get(entity_id)
assert state
assert state.state == "on"

View File

@ -4,6 +4,7 @@ from typing import Any
from unittest.mock import AsyncMock, patch
from nibe.coil import CoilData
from nibe.exceptions import WriteDeniedException, WriteException, WriteTimeoutException
from nibe.heatpump import Model
import pytest
from syrupy.assertion import SnapshotAssertion
@ -15,6 +16,7 @@ from homeassistant.components.number import (
)
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from . import async_add_model
@ -108,3 +110,64 @@ async def test_set_value(
assert isinstance(coil, CoilData)
assert coil.coil.address == address
assert coil.value == value
@pytest.mark.parametrize(
("exception", "translation_key", "translation_placeholders"),
[
(
WriteDeniedException("denied"),
"write_denied",
{"address": "47398", "value": "25.0"},
),
(
WriteTimeoutException("timeout writing"),
"write_timeout",
{"address": "47398"},
),
(
WriteException("failed"),
"write_failed",
{
"address": "47398",
"value": "25.0",
"error": "failed",
},
),
],
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_set_value_fail(
hass: HomeAssistant,
mock_connection: AsyncMock,
exception: Exception,
translation_key: str,
translation_placeholders: dict[str, Any],
coils: dict[int, Any],
) -> None:
"""Test setting of value."""
value = 25
model = Model.F1155
address = 47398
entity_id = "number.room_sensor_setpoint_s1_47398"
coils[address] = 0
await async_add_model(hass, model)
await hass.async_block_till_done()
assert hass.states.get(entity_id)
mock_connection.write_coil.side_effect = exception
# Write value
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
PLATFORM_DOMAIN,
SERVICE_SET_VALUE,
{ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value},
blocking=True,
)
assert exc_info.value.translation_domain == "nibe_heatpump"
assert exc_info.value.translation_key == translation_key
assert exc_info.value.translation_placeholders == translation_placeholders

View File

@ -35,6 +35,7 @@ from openai.types.responses import (
ResponseWebSearchCallSearchingEvent,
)
from openai.types.responses.response import IncompleteDetails
from openai.types.responses.response_function_web_search import ActionSearch
import pytest
from syrupy.assertion import SnapshotAssertion
@ -95,10 +96,12 @@ def mock_create_stream() -> Generator[AsyncMock]:
)
yield ResponseCreatedEvent(
response=response,
sequence_number=0,
type="response.created",
)
yield ResponseInProgressEvent(
response=response,
sequence_number=0,
type="response.in_progress",
)
response.status = "completed"
@ -123,16 +126,19 @@ def mock_create_stream() -> Generator[AsyncMock]:
if response.status == "incomplete":
yield ResponseIncompleteEvent(
response=response,
sequence_number=0,
type="response.incomplete",
)
elif response.status == "failed":
yield ResponseFailedEvent(
response=response,
sequence_number=0,
type="response.failed",
)
else:
yield ResponseCompletedEvent(
response=response,
sequence_number=0,
type="response.completed",
)
@ -301,7 +307,7 @@ async def test_incomplete_response(
"OpenAI response failed: Rate limit exceeded",
),
(
ResponseErrorEvent(type="error", message="Some error"),
ResponseErrorEvent(type="error", message="Some error", sequence_number=0),
"OpenAI response error: Some error",
),
],
@ -359,6 +365,7 @@ def create_message_item(
status="in_progress",
),
output_index=output_index,
sequence_number=0,
type="response.output_item.added",
),
ResponseContentPartAddedEvent(
@ -366,6 +373,7 @@ def create_message_item(
item_id=id,
output_index=output_index,
part=content,
sequence_number=0,
type="response.content_part.added",
),
]
@ -377,6 +385,7 @@ def create_message_item(
delta=delta,
item_id=id,
output_index=output_index,
sequence_number=0,
type="response.output_text.delta",
)
for delta in text
@ -389,6 +398,7 @@ def create_message_item(
item_id=id,
output_index=output_index,
text="".join(text),
sequence_number=0,
type="response.output_text.done",
),
ResponseContentPartDoneEvent(
@ -396,6 +406,7 @@ def create_message_item(
item_id=id,
output_index=output_index,
part=content,
sequence_number=0,
type="response.content_part.done",
),
ResponseOutputItemDoneEvent(
@ -407,6 +418,7 @@ def create_message_item(
type="message",
),
output_index=output_index,
sequence_number=0,
type="response.output_item.done",
),
]
@ -433,6 +445,7 @@ def create_function_tool_call_item(
status="in_progress",
),
output_index=output_index,
sequence_number=0,
type="response.output_item.added",
)
]
@ -442,6 +455,7 @@ def create_function_tool_call_item(
delta=delta,
item_id=id,
output_index=output_index,
sequence_number=0,
type="response.function_call_arguments.delta",
)
for delta in arguments
@ -452,6 +466,7 @@ def create_function_tool_call_item(
arguments="".join(arguments),
item_id=id,
output_index=output_index,
sequence_number=0,
type="response.function_call_arguments.done",
)
)
@ -467,6 +482,7 @@ def create_function_tool_call_item(
status="completed",
),
output_index=output_index,
sequence_number=0,
type="response.output_item.done",
)
)
@ -485,6 +501,7 @@ def create_reasoning_item(id: str, output_index: int) -> list[ResponseStreamEven
status=None,
),
output_index=output_index,
sequence_number=0,
type="response.output_item.added",
),
ResponseOutputItemDoneEvent(
@ -495,6 +512,7 @@ def create_reasoning_item(id: str, output_index: int) -> list[ResponseStreamEven
status=None,
),
output_index=output_index,
sequence_number=0,
type="response.output_item.done",
),
]
@ -505,31 +523,42 @@ def create_web_search_item(id: str, output_index: int) -> list[ResponseStreamEve
return [
ResponseOutputItemAddedEvent(
item=ResponseFunctionWebSearch(
id=id, status="in_progress", type="web_search_call"
id=id,
status="in_progress",
action=ActionSearch(query="query", type="search"),
type="web_search_call",
),
output_index=output_index,
sequence_number=0,
type="response.output_item.added",
),
ResponseWebSearchCallInProgressEvent(
item_id=id,
output_index=output_index,
sequence_number=0,
type="response.web_search_call.in_progress",
),
ResponseWebSearchCallSearchingEvent(
item_id=id,
output_index=output_index,
sequence_number=0,
type="response.web_search_call.searching",
),
ResponseWebSearchCallCompletedEvent(
item_id=id,
output_index=output_index,
sequence_number=0,
type="response.web_search_call.completed",
),
ResponseOutputItemDoneEvent(
item=ResponseFunctionWebSearch(
id=id, status="completed", type="web_search_call"
id=id,
status="completed",
action=ActionSearch(query="query", type="search"),
type="web_search_call",
),
output_index=output_index,
sequence_number=0,
type="response.output_item.done",
),
]
@ -588,6 +617,7 @@ async def test_function_call(
"id": "rs_A",
"summary": [],
"type": "reasoning",
"encrypted_content": None,
}
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
# Don't test the prompt, as it's not deterministic

View File

@ -1,6 +1,7 @@
"""The tests for the REST sensor platform."""
from http import HTTPStatus
import logging
import ssl
from unittest.mock import patch
@ -19,6 +20,14 @@ from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_ENTITY_ID,
ATTR_UNIT_OF_MEASUREMENT,
CONF_DEVICE_CLASS,
CONF_FORCE_UPDATE,
CONF_METHOD,
CONF_NAME,
CONF_PARAMS,
CONF_RESOURCE,
CONF_UNIT_OF_MEASUREMENT,
CONF_VALUE_TEMPLATE,
CONTENT_TYPE_JSON,
SERVICE_RELOAD,
STATE_UNAVAILABLE,
@ -162,6 +171,94 @@ async def test_setup_encoding(
assert hass.states.get("sensor.mysensor").state == "tack själv"
async def test_setup_auto_encoding_from_content_type(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test setup with encoding auto-detected from Content-Type header."""
# Test with ISO-8859-1 charset in Content-Type header
aioclient_mock.get(
"http://localhost",
status=HTTPStatus.OK,
content="Björk Guðmundsdóttir".encode("iso-8859-1"),
headers={"Content-Type": "text/plain; charset=iso-8859-1"},
)
assert await async_setup_component(
hass,
SENSOR_DOMAIN,
{
SENSOR_DOMAIN: {
"name": "mysensor",
# encoding defaults to UTF-8, but should be ignored when charset present
"platform": DOMAIN,
"resource": "http://localhost",
"method": "GET",
}
},
)
await hass.async_block_till_done()
assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1
assert hass.states.get("sensor.mysensor").state == "Björk Guðmundsdóttir"
async def test_setup_encoding_fallback_no_charset(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test that configured encoding is used when no charset in Content-Type."""
# No charset in Content-Type header
aioclient_mock.get(
"http://localhost",
status=HTTPStatus.OK,
content="Björk Guðmundsdóttir".encode("iso-8859-1"),
headers={"Content-Type": "text/plain"}, # No charset!
)
assert await async_setup_component(
hass,
SENSOR_DOMAIN,
{
SENSOR_DOMAIN: {
"name": "mysensor",
"encoding": "iso-8859-1", # This will be used as fallback
"platform": DOMAIN,
"resource": "http://localhost",
"method": "GET",
}
},
)
await hass.async_block_till_done()
assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1
assert hass.states.get("sensor.mysensor").state == "Björk Guðmundsdóttir"
async def test_setup_charset_overrides_encoding_config(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test that charset in Content-Type overrides configured encoding."""
# Server sends UTF-8 with correct charset header
aioclient_mock.get(
"http://localhost",
status=HTTPStatus.OK,
content="Björk Guðmundsdóttir".encode(),
headers={"Content-Type": "text/plain; charset=utf-8"},
)
assert await async_setup_component(
hass,
SENSOR_DOMAIN,
{
SENSOR_DOMAIN: {
"name": "mysensor",
"encoding": "iso-8859-1", # Config says ISO-8859-1, but charset=utf-8 should win
"platform": DOMAIN,
"resource": "http://localhost",
"method": "GET",
}
},
)
await hass.async_block_till_done()
assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1
# This should work because charset=utf-8 overrides the iso-8859-1 config
assert hass.states.get("sensor.mysensor").state == "Björk Guðmundsdóttir"
@pytest.mark.parametrize(
("ssl_cipher_list", "ssl_cipher_list_expected"),
[
@ -978,6 +1075,124 @@ async def test_update_with_failed_get(
assert "Empty reply" in caplog.text
async def test_query_param_dict_value(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test dict values in query params are handled for backward compatibility."""
# Mock response
aioclient_mock.post(
"https://www.envertecportal.com/ApiInverters/QueryTerminalReal",
status=HTTPStatus.OK,
json={"Data": {"QueryResults": [{"POWER": 1500}]}},
)
# This test checks that when template_complex processes a string that looks like
# a dict/list, it converts it to an actual dict/list, which then needs to be
# handled by our backward compatibility code
with caplog.at_level(logging.DEBUG, logger="homeassistant.components.rest.data"):
assert await async_setup_component(
hass,
DOMAIN,
{
DOMAIN: [
{
CONF_RESOURCE: (
"https://www.envertecportal.com/ApiInverters/"
"QueryTerminalReal"
),
CONF_METHOD: "POST",
CONF_PARAMS: {
"page": "1",
"perPage": "20",
"orderBy": "SN",
# When processed by template.render_complex, certain
# strings might be converted to dicts/lists if they
# look like JSON
"whereCondition": (
"{{ {'STATIONID': 'A6327A17797C1234'} }}"
), # Template that evaluates to dict
},
"sensor": [
{
CONF_NAME: "Solar MPPT1 Power",
CONF_VALUE_TEMPLATE: (
"{{ value_json.Data.QueryResults[0].POWER }}"
),
CONF_DEVICE_CLASS: "power",
CONF_UNIT_OF_MEASUREMENT: "W",
CONF_FORCE_UPDATE: True,
"state_class": "measurement",
}
],
}
]
},
)
await hass.async_block_till_done()
# The sensor should be created successfully with backward compatibility
assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1
state = hass.states.get("sensor.solar_mppt1_power")
assert state is not None
assert state.state == "1500"
# Check that a debug message was logged about the parameter conversion
assert "REST query parameter 'whereCondition' has type" in caplog.text
assert "converting to string" in caplog.text
async def test_query_param_json_string_preserved(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that JSON strings in query params are preserved and not converted to dicts."""
# Mock response
aioclient_mock.get(
"https://api.example.com/data",
status=HTTPStatus.OK,
json={"value": 42},
)
# Config with JSON string (quoted) - should remain a string
assert await async_setup_component(
hass,
DOMAIN,
{
DOMAIN: [
{
CONF_RESOURCE: "https://api.example.com/data",
CONF_METHOD: "GET",
CONF_PARAMS: {
"filter": '{"type": "sensor", "id": 123}', # JSON string
"normal": "value",
},
"sensor": [
{
CONF_NAME: "Test Sensor",
CONF_VALUE_TEMPLATE: "{{ value_json.value }}",
}
],
}
]
},
)
await hass.async_block_till_done()
# Check the sensor was created
assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1
state = hass.states.get("sensor.test_sensor")
assert state is not None
assert state.state == "42"
# Verify the request was made with the JSON string intact
assert len(aioclient_mock.mock_calls) == 1
method, url, data, headers = aioclient_mock.mock_calls[0]
assert url.query["filter"] == '{"type": "sensor", "id": 123}'
assert url.query["normal"] == "value"
async def test_reload(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None:
"""Verify we can reload reset sensors."""

View File

@ -214,25 +214,12 @@ class MockSoCo(MagicMock):
surround_level = 3
music_surround_level = 4
soundbar_audio_input_format = "Dolby 5.1"
factory: SoCoMockFactory | None = None
@property
def visible_zones(self):
"""Return visible zones and allow property to be overridden by device classes."""
return {self}
@property
def all_zones(self) -> set[MockSoCo]:
"""Return a set of all mock zones, or just self if no factory or zones."""
if self.factory is not None:
if zones := self.factory.mock_all_zones:
return zones
return {self}
def set_factory(self, factory: SoCoMockFactory) -> None:
"""Set the factory for this mock."""
self.factory = factory
class SoCoMockFactory:
"""Factory for creating SoCo Mocks."""
@ -257,19 +244,11 @@ class SoCoMockFactory:
self.sonos_playlists = sonos_playlists
self.sonos_queue = sonos_queue
@property
def mock_all_zones(self) -> set[MockSoCo]:
"""Return a set of all mock zones."""
return {
mock for mock in self.mock_list.values() if mock.mock_include_in_all_zones
}
def cache_mock(
self, mock_soco: MockSoCo, ip_address: str, name: str = "Zone A"
) -> MockSoCo:
"""Put a user created mock into the cache."""
mock_soco.mock_add_spec(SoCo)
mock_soco.set_factory(self)
mock_soco.ip_address = ip_address
if ip_address != "192.168.42.2":
mock_soco.uid += f"_{ip_address}"
@ -281,11 +260,6 @@ class SoCoMockFactory:
my_speaker_info = self.speaker_info.copy()
my_speaker_info["zone_name"] = name
my_speaker_info["uid"] = mock_soco.uid
# Generate a different MAC for the non-default speakers.
# otherwise new devices will not be created.
if ip_address != "192.168.42.2":
last_octet = ip_address.split(".")[-1]
my_speaker_info["mac_address"] = f"00-00-00-00-00-{last_octet.zfill(2)}"
mock_soco.get_speaker_info = Mock(return_value=my_speaker_info)
mock_soco.add_to_queue = Mock(return_value=10)
mock_soco.add_uri_to_queue = Mock(return_value=10)
@ -304,7 +278,7 @@ class SoCoMockFactory:
mock_soco.alarmClock = self.alarm_clock
mock_soco.get_battery_info.return_value = self.battery_info
mock_soco.mock_include_in_all_zones = True
mock_soco.all_zones = {mock_soco}
mock_soco.group.coordinator = mock_soco
mock_soco.household_id = "test_household_id"
self.mock_list[ip_address] = mock_soco

View File

@ -324,15 +324,10 @@ async def test_async_poll_manual_hosts_5(
soco_1 = soco_factory.cache_mock(MockSoCo(), "10.10.10.1", "Living Room")
soco_1.renderingControl = Mock()
soco_1.renderingControl.GetVolume = Mock()
# Unavailable speakers should not be included in all zones
soco_1.mock_include_in_all_zones = False
speaker_1_activity = SpeakerActivity(hass, soco_1)
soco_2 = soco_factory.cache_mock(MockSoCo(), "10.10.10.2", "Bedroom")
soco_2.renderingControl = Mock()
soco_2.renderingControl.GetVolume = Mock()
soco_2.mock_include_in_all_zones = False
speaker_2_activity = SpeakerActivity(hass, soco_2)
with caplog.at_level(logging.DEBUG):

View File

@ -26,10 +26,10 @@ from homeassistant.const import (
STATE_ON,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers import entity_registry as er
from homeassistant.util import dt as dt_util
from .conftest import MockSoCo, SonosMockEvent, SonosMockService
from .conftest import MockSoCo, SonosMockEvent
from tests.common import async_fire_time_changed
@ -211,53 +211,3 @@ async def test_alarm_create_delete(
assert "switch.sonos_alarm_14" in entity_registry.entities
assert "switch.sonos_alarm_15" not in entity_registry.entities
async def test_alarm_change_device(
hass: HomeAssistant,
async_setup_sonos,
soco: MockSoCo,
alarm_clock: SonosMockService,
alarm_clock_extended: SonosMockService,
alarm_event: SonosMockEvent,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
sonos_setup_two_speakers: list[MockSoCo],
) -> None:
"""Test Sonos Alarm being moved to a different speaker.
This test simulates a scenario where an alarm is created on one speaker
and then moved to another speaker. It checks that the entity is correctly
created on the new speaker and removed from the old one.
"""
entity_id = "switch.sonos_alarm_14"
soco_lr = sonos_setup_two_speakers[0]
await async_setup_sonos()
# Initially, the alarm is created on the soco mock
assert entity_id in entity_registry.entities
entity = entity_registry.async_get(entity_id)
device = device_registry.async_get(entity.device_id)
assert device.name == soco.get_speaker_info()["zone_name"]
# Simulate the alarm being moved to the soco_lr speaker
alarm_update = copy(alarm_clock_extended.ListAlarms.return_value)
alarm_update["CurrentAlarmList"] = alarm_update["CurrentAlarmList"].replace(
"RINCON_test", f"{soco_lr.uid}"
)
alarm_clock.ListAlarms.return_value = alarm_update
# Update the alarm_list_version so it gets processed.
alarm_event.variables["alarm_list_version"] = f"{soco_lr.uid}:1000"
alarm_update["CurrentAlarmListVersion"] = alarm_event.increment_variable(
"alarm_list_version"
)
alarm_clock.subscribe.return_value.callback(event=alarm_event)
await hass.async_block_till_done(wait_background_tasks=True)
assert entity_id in entity_registry.entities
alarm_14 = entity_registry.async_get(entity_id)
device = device_registry.async_get(alarm_14.device_id)
assert device.name == soco_lr.get_speaker_info()["zone_name"]

View File

@ -562,16 +562,10 @@ async def test_vacuum_log_deprecated_battery_properties_using_attr(
# Test we only log once
assert (
"Detected that custom integration 'test' is setting the battery_level which has been deprecated."
" Integration test should implement a sensor instead with a correct device class and link it to"
" the same device. This will stop working in Home Assistant 2026.8,"
" please report it to the author of the 'test' custom integration"
not in caplog.text
)
assert (
"Detected that custom integration 'test' is setting the battery_icon which has been deprecated."
" Integration test should implement a sensor instead with a correct device class and link it to"
" the same device. This will stop working in Home Assistant 2026.8,"
" please report it to the author of the 'test' custom integration"
not in caplog.text
)
@ -613,3 +607,34 @@ async def test_vacuum_log_deprecated_battery_supported_feature(
", please report it to the author of the 'test' custom integration"
in caplog.text
)
async def test_vacuum_not_log_deprecated_battery_properties_during_init(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test not logging deprecation until after added to hass."""
class MockLegacyVacuum(MockVacuum):
"""Mocked vacuum entity."""
def __init__(self, **kwargs: Any) -> None:
"""Initialize a mock vacuum entity."""
super().__init__(**kwargs)
self._attr_battery_level = 50
@property
def activity(self) -> str:
"""Return the state of the entity."""
return VacuumActivity.CLEANING
entity = MockLegacyVacuum(
name="Testing",
entity_id="vacuum.test",
)
assert entity.battery_level == 50
assert (
"Detected that custom integration 'test' is setting the battery_level which has been deprecated."
not in caplog.text
)

View File

@ -7,165 +7,22 @@ import pytest
import requests
from homeassistant.components.wallbox.const import (
CHARGER_ADDED_ENERGY_KEY,
CHARGER_ADDED_RANGE_KEY,
CHARGER_CHARGING_POWER_KEY,
CHARGER_CHARGING_SPEED_KEY,
CHARGER_CURRENCY_KEY,
CHARGER_CURRENT_VERSION_KEY,
CHARGER_DATA_KEY,
CHARGER_DATA_POST_L1_KEY,
CHARGER_DATA_POST_L2_KEY,
CHARGER_ECO_SMART_KEY,
CHARGER_ECO_SMART_MODE_KEY,
CHARGER_ECO_SMART_STATUS_KEY,
CHARGER_ENERGY_PRICE_KEY,
CHARGER_FEATURES_KEY,
CHARGER_LOCKED_UNLOCKED_KEY,
CHARGER_MAX_AVAILABLE_POWER_KEY,
CHARGER_MAX_CHARGING_CURRENT_KEY,
CHARGER_MAX_CHARGING_CURRENT_POST_KEY,
CHARGER_MAX_ICP_CURRENT_KEY,
CHARGER_NAME_KEY,
CHARGER_PART_NUMBER_KEY,
CHARGER_PLAN_KEY,
CHARGER_POWER_BOOST_KEY,
CHARGER_SERIAL_NUMBER_KEY,
CHARGER_SOFTWARE_KEY,
CHARGER_STATUS_ID_KEY,
CONF_STATION,
DOMAIN,
)
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from .const import ERROR, REFRESH_TOKEN_TTL, STATUS, TTL, USER_ID
from .const import WALLBOX_AUTHORISATION_RESPONSE, WALLBOX_STATUS_RESPONSE
from tests.common import MockConfigEntry
test_response = {
CHARGER_CHARGING_POWER_KEY: 0,
CHARGER_STATUS_ID_KEY: 193,
CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0,
CHARGER_CHARGING_SPEED_KEY: 0,
CHARGER_ADDED_RANGE_KEY: 150,
CHARGER_ADDED_ENERGY_KEY: 44.697,
CHARGER_NAME_KEY: "WallboxName",
CHARGER_DATA_KEY: {
CHARGER_MAX_CHARGING_CURRENT_KEY: 24,
CHARGER_ENERGY_PRICE_KEY: 0.4,
CHARGER_LOCKED_UNLOCKED_KEY: False,
CHARGER_SERIAL_NUMBER_KEY: "20000",
CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E",
CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"},
CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"},
CHARGER_MAX_ICP_CURRENT_KEY: 20,
CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]},
CHARGER_ECO_SMART_KEY: {
CHARGER_ECO_SMART_STATUS_KEY: False,
CHARGER_ECO_SMART_MODE_KEY: 0,
},
},
}
test_response_bidir = {
CHARGER_CHARGING_POWER_KEY: 0,
CHARGER_STATUS_ID_KEY: 193,
CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0,
CHARGER_CHARGING_SPEED_KEY: 0,
CHARGER_ADDED_RANGE_KEY: 150,
CHARGER_ADDED_ENERGY_KEY: 44.697,
CHARGER_NAME_KEY: "WallboxName",
CHARGER_DATA_KEY: {
CHARGER_MAX_CHARGING_CURRENT_KEY: 24,
CHARGER_ENERGY_PRICE_KEY: 0.4,
CHARGER_LOCKED_UNLOCKED_KEY: False,
CHARGER_SERIAL_NUMBER_KEY: "20000",
CHARGER_PART_NUMBER_KEY: "QSP1-0-2-4-9-002-E",
CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"},
CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"},
CHARGER_MAX_ICP_CURRENT_KEY: 20,
CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]},
CHARGER_ECO_SMART_KEY: {
CHARGER_ECO_SMART_STATUS_KEY: False,
CHARGER_ECO_SMART_MODE_KEY: 0,
},
},
}
test_response_eco_mode = {
CHARGER_CHARGING_POWER_KEY: 0,
CHARGER_STATUS_ID_KEY: 193,
CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0,
CHARGER_CHARGING_SPEED_KEY: 0,
CHARGER_ADDED_RANGE_KEY: 150,
CHARGER_ADDED_ENERGY_KEY: 44.697,
CHARGER_NAME_KEY: "WallboxName",
CHARGER_DATA_KEY: {
CHARGER_MAX_CHARGING_CURRENT_KEY: 24,
CHARGER_ENERGY_PRICE_KEY: 0.4,
CHARGER_LOCKED_UNLOCKED_KEY: False,
CHARGER_SERIAL_NUMBER_KEY: "20000",
CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E",
CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"},
CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"},
CHARGER_MAX_ICP_CURRENT_KEY: 20,
CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]},
CHARGER_ECO_SMART_KEY: {
CHARGER_ECO_SMART_STATUS_KEY: True,
CHARGER_ECO_SMART_MODE_KEY: 0,
},
},
}
test_response_full_solar = {
CHARGER_CHARGING_POWER_KEY: 0,
CHARGER_STATUS_ID_KEY: 193,
CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0,
CHARGER_CHARGING_SPEED_KEY: 0,
CHARGER_ADDED_RANGE_KEY: 150,
CHARGER_ADDED_ENERGY_KEY: 44.697,
CHARGER_NAME_KEY: "WallboxName",
CHARGER_DATA_KEY: {
CHARGER_MAX_CHARGING_CURRENT_KEY: 24,
CHARGER_ENERGY_PRICE_KEY: 0.4,
CHARGER_LOCKED_UNLOCKED_KEY: False,
CHARGER_SERIAL_NUMBER_KEY: "20000",
CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E",
CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"},
CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"},
CHARGER_MAX_ICP_CURRENT_KEY: 20,
CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]},
CHARGER_ECO_SMART_KEY: {
CHARGER_ECO_SMART_STATUS_KEY: True,
CHARGER_ECO_SMART_MODE_KEY: 1,
},
},
}
test_response_no_power_boost = {
CHARGER_CHARGING_POWER_KEY: 0,
CHARGER_STATUS_ID_KEY: 193,
CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0,
CHARGER_CHARGING_SPEED_KEY: 0,
CHARGER_ADDED_RANGE_KEY: 150,
CHARGER_ADDED_ENERGY_KEY: 44.697,
CHARGER_NAME_KEY: "WallboxName",
CHARGER_DATA_KEY: {
CHARGER_MAX_CHARGING_CURRENT_KEY: 24,
CHARGER_ENERGY_PRICE_KEY: 0.4,
CHARGER_LOCKED_UNLOCKED_KEY: False,
CHARGER_SERIAL_NUMBER_KEY: "20000",
CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E",
CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"},
CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"},
CHARGER_MAX_ICP_CURRENT_KEY: 20,
CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: []},
},
}
http_403_error = requests.exceptions.HTTPError()
http_403_error.response = requests.Response()
http_403_error.response.status_code = HTTPStatus.FORBIDDEN
@ -176,45 +33,6 @@ http_429_error = requests.exceptions.HTTPError()
http_429_error.response = requests.Response()
http_429_error.response.status_code = HTTPStatus.TOO_MANY_REQUESTS
authorisation_response = {
"data": {
"attributes": {
"token": "fakekeyhere",
"refresh_token": "refresh_fakekeyhere",
USER_ID: 12345,
TTL: 145656758,
REFRESH_TOKEN_TTL: 145756758,
ERROR: "false",
STATUS: 200,
}
}
}
authorisation_response_unauthorised = {
"data": {
"attributes": {
"token": "fakekeyhere",
"refresh_token": "refresh_fakekeyhere",
USER_ID: 12345,
TTL: 145656758,
REFRESH_TOKEN_TTL: 145756758,
ERROR: "false",
STATUS: 404,
}
}
}
invalid_reauth_response = {
"jwt": "fakekeyhere",
"refresh_token": "refresh_fakekeyhere",
"user_id": 12345,
"ttl": 145656758,
"refresh_token_ttl": 145756758,
"error": False,
"status": 200,
}
@pytest.fixture
def entry(hass: HomeAssistant) -> MockConfigEntry:
@ -237,7 +55,7 @@ def mock_wallbox():
"""Patch Wallbox class for tests."""
with patch("homeassistant.components.wallbox.Wallbox") as mock:
wallbox = MagicMock()
wallbox.authenticate = Mock(return_value=authorisation_response)
wallbox.authenticate = Mock(return_value=WALLBOX_AUTHORISATION_RESPONSE)
wallbox.lockCharger = Mock(
return_value={
CHARGER_DATA_POST_L1_KEY: {
@ -263,7 +81,7 @@ def mock_wallbox():
}
)
wallbox.setIcpMaxCurrent = Mock(return_value={CHARGER_MAX_ICP_CURRENT_KEY: 25})
wallbox.getChargerStatus = Mock(return_value=test_response)
wallbox.getChargerStatus = Mock(return_value=WALLBOX_STATUS_RESPONSE)
mock.return_value = wallbox
yield wallbox

View File

@ -1,5 +1,31 @@
"""Provides constants for Wallbox component tests."""
from homeassistant.components.wallbox.const import (
CHARGER_ADDED_ENERGY_KEY,
CHARGER_ADDED_RANGE_KEY,
CHARGER_CHARGING_POWER_KEY,
CHARGER_CHARGING_SPEED_KEY,
CHARGER_CURRENCY_KEY,
CHARGER_CURRENT_VERSION_KEY,
CHARGER_DATA_KEY,
CHARGER_ECO_SMART_KEY,
CHARGER_ECO_SMART_MODE_KEY,
CHARGER_ECO_SMART_STATUS_KEY,
CHARGER_ENERGY_PRICE_KEY,
CHARGER_FEATURES_KEY,
CHARGER_LOCKED_UNLOCKED_KEY,
CHARGER_MAX_AVAILABLE_POWER_KEY,
CHARGER_MAX_CHARGING_CURRENT_KEY,
CHARGER_MAX_ICP_CURRENT_KEY,
CHARGER_NAME_KEY,
CHARGER_PART_NUMBER_KEY,
CHARGER_PLAN_KEY,
CHARGER_POWER_BOOST_KEY,
CHARGER_SERIAL_NUMBER_KEY,
CHARGER_SOFTWARE_KEY,
CHARGER_STATUS_ID_KEY,
)
JWT = "jwt"
USER_ID = "user_id"
TTL = "ttl"
@ -7,6 +33,169 @@ REFRESH_TOKEN_TTL = "refresh_token_ttl"
ERROR = "error"
STATUS = "status"
WALLBOX_STATUS_RESPONSE = {
CHARGER_CHARGING_POWER_KEY: 0,
CHARGER_STATUS_ID_KEY: 193,
CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0,
CHARGER_CHARGING_SPEED_KEY: 0,
CHARGER_ADDED_RANGE_KEY: 150,
CHARGER_ADDED_ENERGY_KEY: 44.697,
CHARGER_NAME_KEY: "WallboxName",
CHARGER_DATA_KEY: {
CHARGER_MAX_CHARGING_CURRENT_KEY: 24,
CHARGER_ENERGY_PRICE_KEY: 0.4,
CHARGER_LOCKED_UNLOCKED_KEY: False,
CHARGER_SERIAL_NUMBER_KEY: "20000",
CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E",
CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"},
CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"},
CHARGER_MAX_ICP_CURRENT_KEY: 20,
CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]},
CHARGER_ECO_SMART_KEY: {
CHARGER_ECO_SMART_STATUS_KEY: False,
CHARGER_ECO_SMART_MODE_KEY: 0,
},
},
}
WALLBOX_STATUS_RESPONSE_BIDIR = {
CHARGER_CHARGING_POWER_KEY: 0,
CHARGER_STATUS_ID_KEY: 193,
CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0,
CHARGER_CHARGING_SPEED_KEY: 0,
CHARGER_ADDED_RANGE_KEY: 150,
CHARGER_ADDED_ENERGY_KEY: 44.697,
CHARGER_NAME_KEY: "WallboxName",
CHARGER_DATA_KEY: {
CHARGER_MAX_CHARGING_CURRENT_KEY: 24,
CHARGER_ENERGY_PRICE_KEY: 0.4,
CHARGER_LOCKED_UNLOCKED_KEY: False,
CHARGER_SERIAL_NUMBER_KEY: "20000",
CHARGER_PART_NUMBER_KEY: "QSP1-0-2-4-9-002-E",
CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"},
CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"},
CHARGER_MAX_ICP_CURRENT_KEY: 20,
CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]},
CHARGER_ECO_SMART_KEY: {
CHARGER_ECO_SMART_STATUS_KEY: False,
CHARGER_ECO_SMART_MODE_KEY: 0,
},
},
}
WALLBOX_STATUS_RESPONSE_ECO_MODE = {
CHARGER_CHARGING_POWER_KEY: 0,
CHARGER_STATUS_ID_KEY: 193,
CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0,
CHARGER_CHARGING_SPEED_KEY: 0,
CHARGER_ADDED_RANGE_KEY: 150,
CHARGER_ADDED_ENERGY_KEY: 44.697,
CHARGER_NAME_KEY: "WallboxName",
CHARGER_DATA_KEY: {
CHARGER_MAX_CHARGING_CURRENT_KEY: 24,
CHARGER_ENERGY_PRICE_KEY: 0.4,
CHARGER_LOCKED_UNLOCKED_KEY: False,
CHARGER_SERIAL_NUMBER_KEY: "20000",
CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E",
CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"},
CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"},
CHARGER_MAX_ICP_CURRENT_KEY: 20,
CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]},
CHARGER_ECO_SMART_KEY: {
CHARGER_ECO_SMART_STATUS_KEY: True,
CHARGER_ECO_SMART_MODE_KEY: 0,
},
},
}
WALLBOX_STATUS_RESPONSE_FULL_SOLAR = {
CHARGER_CHARGING_POWER_KEY: 0,
CHARGER_STATUS_ID_KEY: 193,
CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0,
CHARGER_CHARGING_SPEED_KEY: 0,
CHARGER_ADDED_RANGE_KEY: 150,
CHARGER_ADDED_ENERGY_KEY: 44.697,
CHARGER_NAME_KEY: "WallboxName",
CHARGER_DATA_KEY: {
CHARGER_MAX_CHARGING_CURRENT_KEY: 24,
CHARGER_ENERGY_PRICE_KEY: 0.4,
CHARGER_LOCKED_UNLOCKED_KEY: False,
CHARGER_SERIAL_NUMBER_KEY: "20000",
CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E",
CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"},
CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"},
CHARGER_MAX_ICP_CURRENT_KEY: 20,
CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]},
CHARGER_ECO_SMART_KEY: {
CHARGER_ECO_SMART_STATUS_KEY: True,
CHARGER_ECO_SMART_MODE_KEY: 1,
},
},
}
WALLBOX_STATUS_RESPONSE_NO_POWER_BOOST = {
CHARGER_CHARGING_POWER_KEY: 0,
CHARGER_STATUS_ID_KEY: 193,
CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0,
CHARGER_CHARGING_SPEED_KEY: 0,
CHARGER_ADDED_RANGE_KEY: 150,
CHARGER_ADDED_ENERGY_KEY: 44.697,
CHARGER_NAME_KEY: "WallboxName",
CHARGER_DATA_KEY: {
CHARGER_MAX_CHARGING_CURRENT_KEY: 24,
CHARGER_ENERGY_PRICE_KEY: 0.4,
CHARGER_LOCKED_UNLOCKED_KEY: False,
CHARGER_SERIAL_NUMBER_KEY: "20000",
CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E",
CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"},
CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"},
CHARGER_MAX_ICP_CURRENT_KEY: 20,
CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: []},
},
}
WALLBOX_AUTHORISATION_RESPONSE = {
"data": {
"attributes": {
"token": "fakekeyhere",
"refresh_token": "refresh_fakekeyhere",
USER_ID: 12345,
TTL: 145656758,
REFRESH_TOKEN_TTL: 145756758,
ERROR: "false",
STATUS: 200,
}
}
}
WALLBOX_AUTHORISATION_RESPONSE_UNAUTHORISED = {
"data": {
"attributes": {
"token": "fakekeyhere",
"refresh_token": "refresh_fakekeyhere",
USER_ID: 12345,
TTL: 145656758,
REFRESH_TOKEN_TTL: 145756758,
ERROR: "false",
STATUS: 404,
}
}
}
WALLBOX_INVALID_REAUTH_RESPONSE = {
"jwt": "fakekeyhere",
"refresh_token": "refresh_fakekeyhere",
"user_id": 12345,
"ttl": 145656758,
"refresh_token_ttl": 145756758,
"error": False,
"status": 200,
}
MOCK_NUMBER_ENTITY_ID = "number.wallbox_wallboxname_maximum_charging_current"
MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID = "number.wallbox_wallboxname_energy_price"
MOCK_NUMBER_ENTITY_ICP_CURRENT_ID = "number.wallbox_wallboxname_maximum_icp_current"

View File

@ -3,7 +3,6 @@
from unittest.mock import Mock, patch
from homeassistant import config_entries
from homeassistant.components.wallbox import config_flow
from homeassistant.components.wallbox.const import (
CHARGER_ADDED_ENERGY_KEY,
CHARGER_ADDED_RANGE_KEY,
@ -18,12 +17,10 @@ from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from .conftest import (
authorisation_response,
authorisation_response_unauthorised,
http_403_error,
http_404_error,
setup_integration,
from .conftest import http_403_error, http_404_error, setup_integration
from .const import (
WALLBOX_AUTHORISATION_RESPONSE,
WALLBOX_AUTHORISATION_RESPONSE_UNAUTHORISED,
)
from tests.common import MockConfigEntry
@ -40,10 +37,9 @@ test_response = {
async def test_show_set_form(hass: HomeAssistant, mock_wallbox) -> None:
"""Test that the setup form is served."""
flow = config_flow.WallboxConfigFlow()
flow.hass = hass
result = await flow.async_step_user(user_input=None)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
@ -112,7 +108,7 @@ async def test_form_validate_input(hass: HomeAssistant) -> None:
with (
patch(
"homeassistant.components.wallbox.Wallbox.authenticate",
return_value=authorisation_response,
return_value=WALLBOX_AUTHORISATION_RESPONSE,
),
patch(
"homeassistant.components.wallbox.Wallbox.pauseChargingSession",
@ -143,7 +139,7 @@ async def test_form_reauth(
patch.object(
mock_wallbox,
"authenticate",
return_value=authorisation_response_unauthorised,
return_value=WALLBOX_AUTHORISATION_RESPONSE_UNAUTHORISED,
),
patch.object(mock_wallbox, "getChargerStatus", return_value=test_response),
):
@ -176,7 +172,7 @@ async def test_form_reauth_invalid(
patch.object(
mock_wallbox,
"authenticate",
return_value=authorisation_response_unauthorised,
return_value=WALLBOX_AUTHORISATION_RESPONSE_UNAUTHORISED,
),
patch.object(mock_wallbox, "getChargerStatus", return_value=test_response),
):

View File

@ -1,19 +1,23 @@
"""Test Wallbox Init Component."""
from datetime import datetime, timedelta
from unittest.mock import patch
from homeassistant.components.wallbox.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
import pytest
from .conftest import (
http_403_error,
http_429_error,
setup_integration,
test_response_no_power_boost,
from homeassistant.components.input_number import ATTR_VALUE, SERVICE_SET_VALUE
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from .conftest import http_403_error, http_429_error, setup_integration
from .const import (
MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID,
WALLBOX_STATUS_RESPONSE_NO_POWER_BOOST,
)
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, async_fire_time_changed
async def test_wallbox_setup_unload_entry(
@ -40,24 +44,25 @@ async def test_wallbox_unload_entry_connection_error(
assert entry.state is ConfigEntryState.NOT_LOADED
async def test_wallbox_refresh_failed_connection_error_auth(
async def test_wallbox_refresh_failed_connection_error_too_many_requests(
hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox
) -> None:
"""Test Wallbox setup with connection error."""
await setup_integration(hass, entry)
assert entry.state is ConfigEntryState.LOADED
with patch.object(mock_wallbox, "getChargerStatus", side_effect=http_429_error):
await setup_integration(hass, entry)
assert entry.state is ConfigEntryState.SETUP_RETRY
with patch.object(mock_wallbox, "authenticate", side_effect=http_429_error):
wallbox = hass.data[DOMAIN][entry.entry_id]
await wallbox.async_refresh()
await hass.async_block_till_done()
assert await hass.config_entries.async_unload(entry.entry_id)
assert entry.state is ConfigEntryState.NOT_LOADED
async def test_wallbox_refresh_failed_invalid_auth(
hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox
async def test_wallbox_refresh_failed_error_auth(
hass: HomeAssistant,
entry: MockConfigEntry,
mock_wallbox,
) -> None:
"""Test Wallbox setup with authentication error."""
@ -66,11 +71,31 @@ async def test_wallbox_refresh_failed_invalid_auth(
with (
patch.object(mock_wallbox, "authenticate", side_effect=http_403_error),
patch.object(mock_wallbox, "pauseChargingSession", side_effect=http_403_error),
pytest.raises(HomeAssistantError),
):
wallbox = hass.data[DOMAIN][entry.entry_id]
await hass.services.async_call(
"number",
SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID,
ATTR_VALUE: 1.1,
},
blocking=True,
)
await wallbox.async_refresh()
with (
patch.object(mock_wallbox, "authenticate", side_effect=http_429_error),
pytest.raises(HomeAssistantError),
):
await hass.services.async_call(
"number",
SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID,
ATTR_VALUE: 1.1,
},
blocking=True,
)
assert await hass.config_entries.async_unload(entry.entry_id)
assert entry.state is ConfigEntryState.NOT_LOADED
@ -81,13 +106,10 @@ async def test_wallbox_refresh_failed_http_error(
) -> None:
"""Test Wallbox setup with authentication error."""
await setup_integration(hass, entry)
assert entry.state is ConfigEntryState.LOADED
with patch.object(mock_wallbox, "getChargerStatus", side_effect=http_403_error):
wallbox = hass.data[DOMAIN][entry.entry_id]
await wallbox.async_refresh()
await setup_integration(hass, entry)
assert entry.state is ConfigEntryState.SETUP_RETRY
await hass.async_block_till_done()
assert await hass.config_entries.async_unload(entry.entry_id)
assert entry.state is ConfigEntryState.NOT_LOADED
@ -102,9 +124,8 @@ async def test_wallbox_refresh_failed_too_many_requests(
assert entry.state is ConfigEntryState.LOADED
with patch.object(mock_wallbox, "getChargerStatus", side_effect=http_429_error):
wallbox = hass.data[DOMAIN][entry.entry_id]
await wallbox.async_refresh()
async_fire_time_changed(hass, datetime.now() + timedelta(seconds=120), True)
await hass.async_block_till_done()
assert await hass.config_entries.async_unload(entry.entry_id)
assert entry.state is ConfigEntryState.NOT_LOADED
@ -119,9 +140,8 @@ async def test_wallbox_refresh_failed_connection_error(
assert entry.state is ConfigEntryState.LOADED
with patch.object(mock_wallbox, "pauseChargingSession", side_effect=http_403_error):
wallbox = hass.data[DOMAIN][entry.entry_id]
await wallbox.async_refresh()
async_fire_time_changed(hass, datetime.now() + timedelta(seconds=120), True)
await hass.async_block_till_done()
assert await hass.config_entries.async_unload(entry.entry_id)
assert entry.state is ConfigEntryState.NOT_LOADED
@ -132,7 +152,9 @@ async def test_wallbox_setup_load_entry_no_eco_mode(
) -> None:
"""Test Wallbox Unload."""
with patch.object(
mock_wallbox, "getChargerStatus", return_value=test_response_no_power_boost
mock_wallbox,
"getChargerStatus",
return_value=WALLBOX_STATUS_RESPONSE_NO_POWER_BOOST,
):
await setup_integration(hass, entry)
assert entry.state is ConfigEntryState.LOADED

View File

@ -11,17 +11,12 @@ from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from .conftest import (
http_403_error,
http_404_error,
http_429_error,
setup_integration,
test_response_bidir,
)
from .conftest import http_403_error, http_404_error, http_429_error, setup_integration
from .const import (
MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID,
MOCK_NUMBER_ENTITY_ICP_CURRENT_ID,
MOCK_NUMBER_ENTITY_ID,
WALLBOX_STATUS_RESPONSE_BIDIR,
)
from tests.common import MockConfigEntry
@ -53,7 +48,7 @@ async def test_wallbox_number_power_class_bidir(
) -> None:
"""Test wallbox sensor class."""
with patch.object(
mock_wallbox, "getChargerStatus", return_value=test_response_bidir
mock_wallbox, "getChargerStatus", return_value=WALLBOX_STATUS_RESPONSE_BIDIR
):
await setup_integration(hass, entry)

View File

@ -13,23 +13,21 @@ from homeassistant.components.wallbox.const import EcoSmartMode
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant, HomeAssistantError
from .conftest import (
http_404_error,
http_429_error,
setup_integration,
test_response,
test_response_eco_mode,
test_response_full_solar,
test_response_no_power_boost,
from .conftest import http_404_error, http_429_error, setup_integration
from .const import (
MOCK_SELECT_ENTITY_ID,
WALLBOX_STATUS_RESPONSE,
WALLBOX_STATUS_RESPONSE_ECO_MODE,
WALLBOX_STATUS_RESPONSE_FULL_SOLAR,
WALLBOX_STATUS_RESPONSE_NO_POWER_BOOST,
)
from .const import MOCK_SELECT_ENTITY_ID
from tests.common import MockConfigEntry
TEST_OPTIONS = [
(EcoSmartMode.OFF, test_response),
(EcoSmartMode.ECO_MODE, test_response_eco_mode),
(EcoSmartMode.FULL_SOLAR, test_response_full_solar),
(EcoSmartMode.OFF, WALLBOX_STATUS_RESPONSE),
(EcoSmartMode.ECO_MODE, WALLBOX_STATUS_RESPONSE_ECO_MODE),
(EcoSmartMode.FULL_SOLAR, WALLBOX_STATUS_RESPONSE_FULL_SOLAR),
]
@ -61,7 +59,9 @@ async def test_wallbox_select_no_power_boost_class(
"""Test wallbox select class."""
with patch.object(
mock_wallbox, "getChargerStatus", return_value=test_response_no_power_boost
mock_wallbox,
"getChargerStatus",
return_value=WALLBOX_STATUS_RESPONSE_NO_POWER_BOOST,
):
await setup_integration(hass, entry)

View File

@ -4,7 +4,12 @@ from aiowebostv import WebOsTvPairError
import pytest
from homeassistant import config_entries
from homeassistant.components.webostv.const import CONF_SOURCES, DOMAIN, LIVE_TV_APP_ID
from homeassistant.components.webostv.const import (
CONF_SOURCES,
DEFAULT_NAME,
DOMAIN,
LIVE_TV_APP_ID,
)
from homeassistant.config_entries import SOURCE_SSDP
from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST, CONF_SOURCE
from homeassistant.core import HomeAssistant
@ -63,6 +68,29 @@ async def test_form(hass: HomeAssistant, client) -> None:
assert config_entry.unique_id == FAKE_UUID
async def test_form_no_model_name(hass: HomeAssistant, client) -> None:
"""Test successful user flow without model name."""
client.tv_info.system = {}
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={CONF_SOURCE: config_entries.SOURCE_USER},
data=MOCK_USER_CONFIG,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "pairing"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == DEFAULT_NAME
config_entry = result["result"]
assert config_entry.unique_id == FAKE_UUID
@pytest.mark.parametrize(
("apps", "inputs"),
[

View File

@ -0,0 +1,203 @@
"""The tests for the location condition."""
import pytest
from homeassistant.components.zone import condition as zone_condition
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConditionError
from homeassistant.helpers import condition, config_validation as cv
async def test_zone_raises(hass: HomeAssistant) -> None:
"""Test that zone raises ConditionError on errors."""
config = {
"condition": "zone",
"entity_id": "device_tracker.cat",
"zone": "zone.home",
}
config = cv.CONDITION_SCHEMA(config)
config = await condition.async_validate_condition_config(hass, config)
test = await condition.async_from_config(hass, config)
with pytest.raises(ConditionError, match="no zone"):
zone_condition.zone(hass, zone_ent=None, entity="sensor.any")
with pytest.raises(ConditionError, match="unknown zone"):
test(hass)
hass.states.async_set(
"zone.home",
"zoning",
{"name": "home", "latitude": 2.1, "longitude": 1.1, "radius": 10},
)
with pytest.raises(ConditionError, match="no entity"):
zone_condition.zone(hass, zone_ent="zone.home", entity=None)
with pytest.raises(ConditionError, match="unknown entity"):
test(hass)
hass.states.async_set(
"device_tracker.cat",
"home",
{"friendly_name": "cat"},
)
with pytest.raises(ConditionError, match="latitude"):
test(hass)
hass.states.async_set(
"device_tracker.cat",
"home",
{"friendly_name": "cat", "latitude": 2.1},
)
with pytest.raises(ConditionError, match="longitude"):
test(hass)
hass.states.async_set(
"device_tracker.cat",
"home",
{"friendly_name": "cat", "latitude": 2.1, "longitude": 1.1},
)
# All okay, now test multiple failed conditions
assert test(hass)
config = {
"condition": "zone",
"entity_id": ["device_tracker.cat", "device_tracker.dog"],
"zone": ["zone.home", "zone.work"],
}
config = cv.CONDITION_SCHEMA(config)
config = await condition.async_validate_condition_config(hass, config)
test = await condition.async_from_config(hass, config)
with pytest.raises(ConditionError, match="dog"):
test(hass)
with pytest.raises(ConditionError, match="work"):
test(hass)
hass.states.async_set(
"zone.work",
"zoning",
{"name": "work", "latitude": 20, "longitude": 10, "radius": 25000},
)
hass.states.async_set(
"device_tracker.dog",
"work",
{"friendly_name": "dog", "latitude": 20.1, "longitude": 10.1},
)
assert test(hass)
async def test_zone_multiple_entities(hass: HomeAssistant) -> None:
"""Test with multiple entities in condition."""
config = {
"condition": "and",
"conditions": [
{
"alias": "Zone Condition",
"condition": "zone",
"entity_id": ["device_tracker.person_1", "device_tracker.person_2"],
"zone": "zone.home",
},
],
}
config = cv.CONDITION_SCHEMA(config)
config = await condition.async_validate_condition_config(hass, config)
test = await condition.async_from_config(hass, config)
hass.states.async_set(
"zone.home",
"zoning",
{"name": "home", "latitude": 2.1, "longitude": 1.1, "radius": 10},
)
hass.states.async_set(
"device_tracker.person_1",
"home",
{"friendly_name": "person_1", "latitude": 2.1, "longitude": 1.1},
)
hass.states.async_set(
"device_tracker.person_2",
"home",
{"friendly_name": "person_2", "latitude": 2.1, "longitude": 1.1},
)
assert test(hass)
hass.states.async_set(
"device_tracker.person_1",
"home",
{"friendly_name": "person_1", "latitude": 20.1, "longitude": 10.1},
)
hass.states.async_set(
"device_tracker.person_2",
"home",
{"friendly_name": "person_2", "latitude": 2.1, "longitude": 1.1},
)
assert not test(hass)
hass.states.async_set(
"device_tracker.person_1",
"home",
{"friendly_name": "person_1", "latitude": 2.1, "longitude": 1.1},
)
hass.states.async_set(
"device_tracker.person_2",
"home",
{"friendly_name": "person_2", "latitude": 20.1, "longitude": 10.1},
)
assert not test(hass)
async def test_multiple_zones(hass: HomeAssistant) -> None:
"""Test with multiple entities in condition."""
config = {
"condition": "and",
"conditions": [
{
"condition": "zone",
"entity_id": "device_tracker.person",
"zone": ["zone.home", "zone.work"],
},
],
}
config = cv.CONDITION_SCHEMA(config)
config = await condition.async_validate_condition_config(hass, config)
test = await condition.async_from_config(hass, config)
hass.states.async_set(
"zone.home",
"zoning",
{"name": "home", "latitude": 2.1, "longitude": 1.1, "radius": 10},
)
hass.states.async_set(
"zone.work",
"zoning",
{"name": "work", "latitude": 20.1, "longitude": 10.1, "radius": 10},
)
hass.states.async_set(
"device_tracker.person",
"home",
{"friendly_name": "person", "latitude": 2.1, "longitude": 1.1},
)
assert test(hass)
hass.states.async_set(
"device_tracker.person",
"home",
{"friendly_name": "person", "latitude": 20.1, "longitude": 10.1},
)
assert test(hass)
hass.states.async_set(
"device_tracker.person",
"home",
{"friendly_name": "person", "latitude": 50.1, "longitude": 20.1},
)
assert not test(hass)

View File

@ -1892,201 +1892,6 @@ async def test_numeric_state_using_input_number(hass: HomeAssistant) -> None:
)
async def test_zone_raises(hass: HomeAssistant) -> None:
"""Test that zone raises ConditionError on errors."""
config = {
"condition": "zone",
"entity_id": "device_tracker.cat",
"zone": "zone.home",
}
config = cv.CONDITION_SCHEMA(config)
config = await condition.async_validate_condition_config(hass, config)
test = await condition.async_from_config(hass, config)
with pytest.raises(ConditionError, match="no zone"):
condition.zone(hass, zone_ent=None, entity="sensor.any")
with pytest.raises(ConditionError, match="unknown zone"):
test(hass)
hass.states.async_set(
"zone.home",
"zoning",
{"name": "home", "latitude": 2.1, "longitude": 1.1, "radius": 10},
)
with pytest.raises(ConditionError, match="no entity"):
condition.zone(hass, zone_ent="zone.home", entity=None)
with pytest.raises(ConditionError, match="unknown entity"):
test(hass)
hass.states.async_set(
"device_tracker.cat",
"home",
{"friendly_name": "cat"},
)
with pytest.raises(ConditionError, match="latitude"):
test(hass)
hass.states.async_set(
"device_tracker.cat",
"home",
{"friendly_name": "cat", "latitude": 2.1},
)
with pytest.raises(ConditionError, match="longitude"):
test(hass)
hass.states.async_set(
"device_tracker.cat",
"home",
{"friendly_name": "cat", "latitude": 2.1, "longitude": 1.1},
)
# All okay, now test multiple failed conditions
assert test(hass)
config = {
"condition": "zone",
"entity_id": ["device_tracker.cat", "device_tracker.dog"],
"zone": ["zone.home", "zone.work"],
}
config = cv.CONDITION_SCHEMA(config)
config = await condition.async_validate_condition_config(hass, config)
test = await condition.async_from_config(hass, config)
with pytest.raises(ConditionError, match="dog"):
test(hass)
with pytest.raises(ConditionError, match="work"):
test(hass)
hass.states.async_set(
"zone.work",
"zoning",
{"name": "work", "latitude": 20, "longitude": 10, "radius": 25000},
)
hass.states.async_set(
"device_tracker.dog",
"work",
{"friendly_name": "dog", "latitude": 20.1, "longitude": 10.1},
)
assert test(hass)
async def test_zone_multiple_entities(hass: HomeAssistant) -> None:
"""Test with multiple entities in condition."""
config = {
"condition": "and",
"conditions": [
{
"alias": "Zone Condition",
"condition": "zone",
"entity_id": ["device_tracker.person_1", "device_tracker.person_2"],
"zone": "zone.home",
},
],
}
config = cv.CONDITION_SCHEMA(config)
config = await condition.async_validate_condition_config(hass, config)
test = await condition.async_from_config(hass, config)
hass.states.async_set(
"zone.home",
"zoning",
{"name": "home", "latitude": 2.1, "longitude": 1.1, "radius": 10},
)
hass.states.async_set(
"device_tracker.person_1",
"home",
{"friendly_name": "person_1", "latitude": 2.1, "longitude": 1.1},
)
hass.states.async_set(
"device_tracker.person_2",
"home",
{"friendly_name": "person_2", "latitude": 2.1, "longitude": 1.1},
)
assert test(hass)
hass.states.async_set(
"device_tracker.person_1",
"home",
{"friendly_name": "person_1", "latitude": 20.1, "longitude": 10.1},
)
hass.states.async_set(
"device_tracker.person_2",
"home",
{"friendly_name": "person_2", "latitude": 2.1, "longitude": 1.1},
)
assert not test(hass)
hass.states.async_set(
"device_tracker.person_1",
"home",
{"friendly_name": "person_1", "latitude": 2.1, "longitude": 1.1},
)
hass.states.async_set(
"device_tracker.person_2",
"home",
{"friendly_name": "person_2", "latitude": 20.1, "longitude": 10.1},
)
assert not test(hass)
async def test_multiple_zones(hass: HomeAssistant) -> None:
"""Test with multiple entities in condition."""
config = {
"condition": "and",
"conditions": [
{
"condition": "zone",
"entity_id": "device_tracker.person",
"zone": ["zone.home", "zone.work"],
},
],
}
config = cv.CONDITION_SCHEMA(config)
config = await condition.async_validate_condition_config(hass, config)
test = await condition.async_from_config(hass, config)
hass.states.async_set(
"zone.home",
"zoning",
{"name": "home", "latitude": 2.1, "longitude": 1.1, "radius": 10},
)
hass.states.async_set(
"zone.work",
"zoning",
{"name": "work", "latitude": 20.1, "longitude": 10.1, "radius": 10},
)
hass.states.async_set(
"device_tracker.person",
"home",
{"friendly_name": "person", "latitude": 2.1, "longitude": 1.1},
)
assert test(hass)
hass.states.async_set(
"device_tracker.person",
"home",
{"friendly_name": "person", "latitude": 20.1, "longitude": 10.1},
)
assert test(hass)
hass.states.async_set(
"device_tracker.person",
"home",
{"friendly_name": "person", "latitude": 50.1, "longitude": 20.1},
)
assert not test(hass)
@pytest.mark.usefixtures("hass")
async def test_extract_entities() -> None:
"""Test extracting entities."""

View File

@ -3,6 +3,7 @@
import asyncio
from collections.abc import Iterable
from copy import deepcopy
import dataclasses
import io
from typing import Any
from unittest.mock import AsyncMock, Mock, patch
@ -2322,3 +2323,80 @@ async def test_reload_service_helper(hass: HomeAssistant) -> None:
]
await asyncio.gather(*tasks)
assert reloaded == unordered(["all", "target1", "target2", "target3", "target4"])
async def test_deprecated_service_target_selector_class(hass: HomeAssistant) -> None:
"""Test that the deprecated ServiceTargetSelector class forwards correctly."""
call = ServiceCall(
hass,
"test",
"test",
{
"entity_id": ["light.test", "switch.test"],
"area_id": "kitchen",
"device_id": ["device1", "device2"],
"floor_id": "first_floor",
"label_id": ["label1", "label2"],
},
)
selector = service.ServiceTargetSelector(call)
assert selector.entity_ids == {"light.test", "switch.test"}
assert selector.area_ids == {"kitchen"}
assert selector.device_ids == {"device1", "device2"}
assert selector.floor_ids == {"first_floor"}
assert selector.label_ids == {"label1", "label2"}
assert selector.has_any_selector is True
async def test_deprecated_selected_entities_class(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:
"""Test that the deprecated SelectedEntities class forwards correctly."""
selected = service.SelectedEntities(
referenced={"entity.test"},
indirectly_referenced=set(),
referenced_devices=set(),
referenced_areas=set(),
missing_devices={"missing_device"},
missing_areas={"missing_area"},
missing_floors={"missing_floor"},
missing_labels={"missing_label"},
)
missing_entities = {"entity.missing"}
selected.log_missing(missing_entities)
assert (
"Referenced floors missing_floor, areas missing_area, "
"devices missing_device, entities entity.missing, "
"labels missing_label are missing or not currently available" in caplog.text
)
async def test_deprecated_async_extract_referenced_entity_ids(
hass: HomeAssistant,
) -> None:
"""Test that the deprecated async_extract_referenced_entity_ids function forwards correctly."""
from homeassistant.helpers import target # noqa: PLC0415
mock_selected = target.SelectedEntities(
referenced={"entity.test"},
indirectly_referenced={"entity.indirect"},
)
with patch(
"homeassistant.helpers.target.async_extract_referenced_entity_ids",
return_value=mock_selected,
) as mock_target_func:
call = ServiceCall(hass, "test", "test", {"entity_id": "light.test"})
result = service.async_extract_referenced_entity_ids(
hass, call, expand_group=False
)
# Verify target helper was called with correct parameters
mock_target_func.assert_called_once()
args = mock_target_func.call_args
assert args[0][0] is hass
assert args[0][1].entity_ids == {"light.test"}
assert args[0][2] is False
assert dataclasses.asdict(result) == dataclasses.asdict(mock_selected)

View File

@ -0,0 +1,459 @@
"""Test service helpers."""
import pytest
# TODO(abmantis): is this import needed?
# To prevent circular import when running just this file
import homeassistant.components # noqa: F401
from homeassistant.components.group import Group
from homeassistant.const import (
ATTR_AREA_ID,
ATTR_DEVICE_ID,
ATTR_ENTITY_ID,
ATTR_FLOOR_ID,
ATTR_LABEL_ID,
ENTITY_MATCH_NONE,
STATE_OFF,
STATE_ON,
EntityCategory,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import (
area_registry as ar,
device_registry as dr,
entity_registry as er,
target,
)
from homeassistant.helpers.typing import ConfigType
from homeassistant.setup import async_setup_component
from tests.common import (
RegistryEntryWithDefaults,
mock_area_registry,
mock_device_registry,
mock_registry,
)
@pytest.fixture
def registries_mock(hass: HomeAssistant) -> None:
"""Mock including floor and area info."""
hass.states.async_set("light.Bowl", STATE_ON)
hass.states.async_set("light.Ceiling", STATE_OFF)
hass.states.async_set("light.Kitchen", STATE_OFF)
area_in_floor = ar.AreaEntry(
id="test-area",
name="Test area",
aliases={},
floor_id="test-floor",
icon=None,
picture=None,
temperature_entity_id=None,
humidity_entity_id=None,
)
area_in_floor_a = ar.AreaEntry(
id="area-a",
name="Area A",
aliases={},
floor_id="floor-a",
icon=None,
picture=None,
temperature_entity_id=None,
humidity_entity_id=None,
)
area_with_labels = ar.AreaEntry(
id="area-with-labels",
name="Area with labels",
aliases={},
floor_id=None,
icon=None,
labels={"label_area"},
picture=None,
temperature_entity_id=None,
humidity_entity_id=None,
)
mock_area_registry(
hass,
{
area_in_floor.id: area_in_floor,
area_in_floor_a.id: area_in_floor_a,
area_with_labels.id: area_with_labels,
},
)
device_in_area = dr.DeviceEntry(id="device-test-area", area_id="test-area")
device_no_area = dr.DeviceEntry(id="device-no-area-id")
device_diff_area = dr.DeviceEntry(id="device-diff-area", area_id="diff-area")
device_area_a = dr.DeviceEntry(id="device-area-a-id", area_id="area-a")
device_has_label1 = dr.DeviceEntry(id="device-has-label1-id", labels={"label1"})
device_has_label2 = dr.DeviceEntry(id="device-has-label2-id", labels={"label2"})
device_has_labels = dr.DeviceEntry(
id="device-has-labels-id",
labels={"label1", "label2"},
area_id=area_with_labels.id,
)
mock_device_registry(
hass,
{
device_in_area.id: device_in_area,
device_no_area.id: device_no_area,
device_diff_area.id: device_diff_area,
device_area_a.id: device_area_a,
device_has_label1.id: device_has_label1,
device_has_label2.id: device_has_label2,
device_has_labels.id: device_has_labels,
},
)
entity_in_own_area = RegistryEntryWithDefaults(
entity_id="light.in_own_area",
unique_id="in-own-area-id",
platform="test",
area_id="own-area",
)
config_entity_in_own_area = RegistryEntryWithDefaults(
entity_id="light.config_in_own_area",
unique_id="config-in-own-area-id",
platform="test",
area_id="own-area",
entity_category=EntityCategory.CONFIG,
)
hidden_entity_in_own_area = RegistryEntryWithDefaults(
entity_id="light.hidden_in_own_area",
unique_id="hidden-in-own-area-id",
platform="test",
area_id="own-area",
hidden_by=er.RegistryEntryHider.USER,
)
entity_in_area = RegistryEntryWithDefaults(
entity_id="light.in_area",
unique_id="in-area-id",
platform="test",
device_id=device_in_area.id,
)
config_entity_in_area = RegistryEntryWithDefaults(
entity_id="light.config_in_area",
unique_id="config-in-area-id",
platform="test",
device_id=device_in_area.id,
entity_category=EntityCategory.CONFIG,
)
hidden_entity_in_area = RegistryEntryWithDefaults(
entity_id="light.hidden_in_area",
unique_id="hidden-in-area-id",
platform="test",
device_id=device_in_area.id,
hidden_by=er.RegistryEntryHider.USER,
)
entity_in_other_area = RegistryEntryWithDefaults(
entity_id="light.in_other_area",
unique_id="in-area-a-id",
platform="test",
device_id=device_in_area.id,
area_id="other-area",
)
entity_assigned_to_area = RegistryEntryWithDefaults(
entity_id="light.assigned_to_area",
unique_id="assigned-area-id",
platform="test",
device_id=device_in_area.id,
area_id="test-area",
)
entity_no_area = RegistryEntryWithDefaults(
entity_id="light.no_area",
unique_id="no-area-id",
platform="test",
device_id=device_no_area.id,
)
config_entity_no_area = RegistryEntryWithDefaults(
entity_id="light.config_no_area",
unique_id="config-no-area-id",
platform="test",
device_id=device_no_area.id,
entity_category=EntityCategory.CONFIG,
)
hidden_entity_no_area = RegistryEntryWithDefaults(
entity_id="light.hidden_no_area",
unique_id="hidden-no-area-id",
platform="test",
device_id=device_no_area.id,
hidden_by=er.RegistryEntryHider.USER,
)
entity_diff_area = RegistryEntryWithDefaults(
entity_id="light.diff_area",
unique_id="diff-area-id",
platform="test",
device_id=device_diff_area.id,
)
entity_in_area_a = RegistryEntryWithDefaults(
entity_id="light.in_area_a",
unique_id="in-area-a-id",
platform="test",
device_id=device_area_a.id,
area_id="area-a",
)
entity_in_area_b = RegistryEntryWithDefaults(
entity_id="light.in_area_b",
unique_id="in-area-b-id",
platform="test",
device_id=device_area_a.id,
area_id="area-b",
)
entity_with_my_label = RegistryEntryWithDefaults(
entity_id="light.with_my_label",
unique_id="with_my_label",
platform="test",
labels={"my-label"},
)
hidden_entity_with_my_label = RegistryEntryWithDefaults(
entity_id="light.hidden_with_my_label",
unique_id="hidden_with_my_label",
platform="test",
labels={"my-label"},
hidden_by=er.RegistryEntryHider.USER,
)
config_entity_with_my_label = RegistryEntryWithDefaults(
entity_id="light.config_with_my_label",
unique_id="config_with_my_label",
platform="test",
labels={"my-label"},
entity_category=EntityCategory.CONFIG,
)
entity_with_label1_from_device = RegistryEntryWithDefaults(
entity_id="light.with_label1_from_device",
unique_id="with_label1_from_device",
platform="test",
device_id=device_has_label1.id,
)
entity_with_label1_from_device_and_different_area = RegistryEntryWithDefaults(
entity_id="light.with_label1_from_device_diff_area",
unique_id="with_label1_from_device_diff_area",
platform="test",
device_id=device_has_label1.id,
area_id=area_in_floor_a.id,
)
entity_with_label1_and_label2_from_device = RegistryEntryWithDefaults(
entity_id="light.with_label1_and_label2_from_device",
unique_id="with_label1_and_label2_from_device",
platform="test",
labels={"label1"},
device_id=device_has_label2.id,
)
entity_with_labels_from_device = RegistryEntryWithDefaults(
entity_id="light.with_labels_from_device",
unique_id="with_labels_from_device",
platform="test",
device_id=device_has_labels.id,
)
mock_registry(
hass,
{
entity_in_own_area.entity_id: entity_in_own_area,
config_entity_in_own_area.entity_id: config_entity_in_own_area,
hidden_entity_in_own_area.entity_id: hidden_entity_in_own_area,
entity_in_area.entity_id: entity_in_area,
config_entity_in_area.entity_id: config_entity_in_area,
hidden_entity_in_area.entity_id: hidden_entity_in_area,
entity_in_other_area.entity_id: entity_in_other_area,
entity_assigned_to_area.entity_id: entity_assigned_to_area,
entity_no_area.entity_id: entity_no_area,
config_entity_no_area.entity_id: config_entity_no_area,
hidden_entity_no_area.entity_id: hidden_entity_no_area,
entity_diff_area.entity_id: entity_diff_area,
entity_in_area_a.entity_id: entity_in_area_a,
entity_in_area_b.entity_id: entity_in_area_b,
config_entity_with_my_label.entity_id: config_entity_with_my_label,
entity_with_label1_and_label2_from_device.entity_id: entity_with_label1_and_label2_from_device,
entity_with_label1_from_device.entity_id: entity_with_label1_from_device,
entity_with_label1_from_device_and_different_area.entity_id: entity_with_label1_from_device_and_different_area,
entity_with_labels_from_device.entity_id: entity_with_labels_from_device,
entity_with_my_label.entity_id: entity_with_my_label,
hidden_entity_with_my_label.entity_id: hidden_entity_with_my_label,
},
)
@pytest.mark.parametrize(
("selector_config", "expand_group", "expected_selected"),
[
(
{
ATTR_ENTITY_ID: ENTITY_MATCH_NONE,
ATTR_AREA_ID: ENTITY_MATCH_NONE,
ATTR_FLOOR_ID: ENTITY_MATCH_NONE,
ATTR_LABEL_ID: ENTITY_MATCH_NONE,
},
False,
target.SelectedEntities(),
),
(
{ATTR_ENTITY_ID: "light.bowl"},
False,
target.SelectedEntities(referenced={"light.bowl"}),
),
(
{ATTR_ENTITY_ID: "group.test"},
True,
target.SelectedEntities(referenced={"light.ceiling", "light.kitchen"}),
),
(
{ATTR_ENTITY_ID: "group.test"},
False,
target.SelectedEntities(referenced={"group.test"}),
),
(
{ATTR_AREA_ID: "own-area"},
False,
target.SelectedEntities(
indirectly_referenced={"light.in_own_area"},
referenced_areas={"own-area"},
missing_areas={"own-area"},
),
),
(
{ATTR_AREA_ID: "test-area"},
False,
target.SelectedEntities(
indirectly_referenced={
"light.in_area",
"light.assigned_to_area",
},
referenced_areas={"test-area"},
referenced_devices={"device-test-area"},
),
),
(
{ATTR_AREA_ID: ["test-area", "diff-area"]},
False,
target.SelectedEntities(
indirectly_referenced={
"light.in_area",
"light.diff_area",
"light.assigned_to_area",
},
referenced_areas={"test-area", "diff-area"},
referenced_devices={"device-diff-area", "device-test-area"},
missing_areas={"diff-area"},
),
),
(
{ATTR_DEVICE_ID: "device-no-area-id"},
False,
target.SelectedEntities(
indirectly_referenced={"light.no_area"},
referenced_devices={"device-no-area-id"},
),
),
(
{ATTR_DEVICE_ID: "device-area-a-id"},
False,
target.SelectedEntities(
indirectly_referenced={"light.in_area_a", "light.in_area_b"},
referenced_devices={"device-area-a-id"},
),
),
(
{ATTR_FLOOR_ID: "test-floor"},
False,
target.SelectedEntities(
indirectly_referenced={"light.in_area", "light.assigned_to_area"},
referenced_devices={"device-test-area"},
referenced_areas={"test-area"},
missing_floors={"test-floor"},
),
),
(
{ATTR_FLOOR_ID: ["test-floor", "floor-a"]},
False,
target.SelectedEntities(
indirectly_referenced={
"light.in_area",
"light.assigned_to_area",
"light.in_area_a",
"light.with_label1_from_device_diff_area",
},
referenced_devices={"device-area-a-id", "device-test-area"},
referenced_areas={"area-a", "test-area"},
missing_floors={"floor-a", "test-floor"},
),
),
(
{ATTR_LABEL_ID: "my-label"},
False,
target.SelectedEntities(
indirectly_referenced={"light.with_my_label"},
missing_labels={"my-label"},
),
),
(
{ATTR_LABEL_ID: "label1"},
False,
target.SelectedEntities(
indirectly_referenced={
"light.with_label1_from_device",
"light.with_label1_from_device_diff_area",
"light.with_labels_from_device",
"light.with_label1_and_label2_from_device",
},
referenced_devices={"device-has-label1-id", "device-has-labels-id"},
missing_labels={"label1"},
),
),
(
{ATTR_LABEL_ID: ["label2"]},
False,
target.SelectedEntities(
indirectly_referenced={
"light.with_labels_from_device",
"light.with_label1_and_label2_from_device",
},
referenced_devices={"device-has-label2-id", "device-has-labels-id"},
missing_labels={"label2"},
),
),
(
{ATTR_LABEL_ID: ["label_area"]},
False,
target.SelectedEntities(
indirectly_referenced={"light.with_labels_from_device"},
referenced_devices={"device-has-labels-id"},
referenced_areas={"area-with-labels"},
missing_labels={"label_area"},
),
),
],
)
@pytest.mark.usefixtures("registries_mock")
async def test_extract_referenced_entity_ids(
hass: HomeAssistant,
selector_config: ConfigType,
expand_group: bool,
expected_selected: target.SelectedEntities,
) -> None:
"""Test extract_entity_ids method."""
hass.states.async_set("light.Bowl", STATE_ON)
hass.states.async_set("light.Ceiling", STATE_OFF)
hass.states.async_set("light.Kitchen", STATE_OFF)
assert await async_setup_component(hass, "group", {})
await hass.async_block_till_done()
await Group.async_create_group(
hass,
"test",
created_by_service=False,
entity_ids=["light.Ceiling", "light.Kitchen"],
icon=None,
mode=None,
object_id=None,
order=None,
)
target_data = target.TargetSelectorData(selector_config)
assert (
target.async_extract_referenced_entity_ids(
hass, target_data, expand_group=expand_group
)
== expected_selected
)

View File

@ -194,7 +194,6 @@ class AiohttpClientMockResponse:
if response is None:
response = b""
self.charset = "utf-8"
self.method = method
self._url = url
self.status = status
@ -264,16 +263,32 @@ class AiohttpClientMockResponse:
"""Return content."""
return mock_stream(self.response)
@property
def charset(self):
"""Return charset from Content-Type header."""
if (content_type := self._headers.get("content-type")) is None:
return None
content_type = content_type.lower()
if "charset=" in content_type:
return content_type.split("charset=")[1].split(";")[0].strip()
return None
async def read(self):
"""Return mock response."""
return self.response
async def text(self, encoding="utf-8", errors="strict"):
async def text(self, encoding=None, errors="strict") -> str:
"""Return mock response as a string."""
# Match real aiohttp behavior: encoding=None means auto-detect
if encoding is None:
encoding = self.charset or "utf-8"
return self.response.decode(encoding, errors=errors)
async def json(self, encoding="utf-8", content_type=None, loads=json_loads):
async def json(self, encoding=None, content_type=None, loads=json_loads) -> Any:
"""Return mock response as a json."""
# Match real aiohttp behavior: encoding=None means auto-detect
if encoding is None:
encoding = self.charset or "utf-8"
return loads(self.response.decode(encoding))
def release(self):