Compare commits

...

14 Commits

Author SHA1 Message Date
J. Nick Koston
a0fd5cd2b2 cover 2025-12-06 18:38:32 -06:00
J. Nick Koston
2fd8ef2428 tweak 2025-12-06 18:31:23 -06:00
J. Nick Koston
ec1880d803 Keep persistent BLE connection during Shelly WiFi provisioning 2025-12-06 18:19:48 -06:00
Philip Cheong
319d6711c4 Add support for LockStatus.JAMMED to yalexs_ble (#157551)
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-12-06 13:17:09 -06:00
J. Nick Koston
ea3f76c315 Bump yalexs-ble to 3.2.2 (#158124) 2025-12-06 12:16:52 -06:00
Shay Levy
b892cc1cad Revert "Remove Shelly redundant device entry check for sleepy devices" (#158108) 2025-12-06 19:40:05 +02:00
Marc Mueller
3046c7afd8 Fix shelly RuntimeWarnings in tests (#158101) 2025-12-06 11:34:24 -06:00
Raphael Hehl
73dc81034e Implement reconfiguration flow for UniFi Protect integration (#157532)
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-12-06 11:32:43 -06:00
Jesse Hills
f306cde3b6 Add response support to esphome custom actions (#157393)
Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-12-06 09:48:20 -06:00
Kevin Stillhammer
38c5e483a8 add entity_picture to fressnapf_tracker (#158099) 2025-12-06 13:28:53 +01:00
Michael
ce14544ec1 Add packet loss sensor to Ping integration (#158081) 2025-12-06 10:57:31 +01:00
mettolen
87b9c3193e Add sensor entities to Airobot integration (#157938) 2025-12-06 07:57:03 +01:00
Adam Goode
061c38d2a7 Make unifi LEDs EntityCategory.CONFIG (#158088) 2025-12-06 07:51:09 +01:00
Allen Porter
e1720be5a4 Update roborock quality scale (#158024) 2025-12-05 22:52:38 +01:00
38 changed files with 3077 additions and 571 deletions

View File

@@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant
from .coordinator import AirobotConfigEntry, AirobotDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.CLIMATE]
PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: AirobotConfigEntry) -> bool:

View File

@@ -44,7 +44,7 @@ rules:
discovery: done
docs-data-update: done
docs-examples: todo
docs-known-limitations: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
@@ -54,7 +54,7 @@ rules:
comment: Single device integration, no dynamic device discovery needed.
entity-category: done
entity-device-class: done
entity-disabled-by-default: todo
entity-disabled-by-default: done
entity-translations: todo
exception-translations: done
icon-translations: todo

View File

@@ -0,0 +1,134 @@
"""Sensor platform for Airobot thermostat."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from pyairobotrest.models import ThermostatStatus
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
CONCENTRATION_PARTS_PER_MILLION,
PERCENTAGE,
EntityCategory,
UnitOfTemperature,
UnitOfTime,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import AirobotConfigEntry
from .entity import AirobotEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class AirobotSensorEntityDescription(SensorEntityDescription):
"""Describes Airobot sensor entity."""
value_fn: Callable[[ThermostatStatus], StateType]
supported_fn: Callable[[ThermostatStatus], bool] = lambda _: True
SENSOR_TYPES: tuple[AirobotSensorEntityDescription, ...] = (
AirobotSensorEntityDescription(
key="air_temperature",
translation_key="air_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.temp_air,
),
AirobotSensorEntityDescription(
key="humidity",
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.hum_air,
),
AirobotSensorEntityDescription(
key="floor_temperature",
translation_key="floor_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.temp_floor,
supported_fn=lambda status: status.has_floor_sensor,
),
AirobotSensorEntityDescription(
key="co2",
device_class=SensorDeviceClass.CO2,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.co2,
supported_fn=lambda status: status.has_co2_sensor,
),
AirobotSensorEntityDescription(
key="air_quality_index",
device_class=SensorDeviceClass.AQI,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.aqi,
supported_fn=lambda status: status.has_co2_sensor,
),
AirobotSensorEntityDescription(
key="heating_uptime",
translation_key="heating_uptime",
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
suggested_unit_of_measurement=UnitOfTime.HOURS,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda status: status.heating_uptime,
entity_registry_enabled_default=False,
),
AirobotSensorEntityDescription(
key="errors",
translation_key="errors",
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda status: status.errors,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AirobotConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Airobot sensor platform."""
coordinator = entry.runtime_data
async_add_entities(
AirobotSensor(coordinator, description)
for description in SENSOR_TYPES
if description.supported_fn(coordinator.data.status)
)
class AirobotSensor(AirobotEntity, SensorEntity):
"""Representation of an Airobot sensor."""
entity_description: AirobotSensorEntityDescription
def __init__(
self,
coordinator,
description: AirobotSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.data.status.device_id}_{description.key}"
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.data.status)

View File

@@ -43,6 +43,25 @@
}
}
},
"entity": {
"sensor": {
"air_temperature": {
"name": "Air temperature"
},
"device_uptime": {
"name": "Device uptime"
},
"errors": {
"name": "Error count"
},
"floor_temperature": {
"name": "Floor temperature"
},
"heating_uptime": {
"name": "Heating uptime"
}
}
},
"exceptions": {
"authentication_failed": {
"message": "Authentication failed, please reauthenticate."

View File

@@ -29,5 +29,5 @@
"documentation": "https://www.home-assistant.io/integrations/august",
"iot_class": "cloud_push",
"loggers": ["pubnub", "yalexs"],
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.2.1"]
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.2.2"]
}

View File

@@ -15,12 +15,14 @@ from aioesphomeapi import (
APIVersion,
DeviceInfo as EsphomeDeviceInfo,
EncryptionPlaintextAPIError,
ExecuteServiceResponse,
HomeassistantServiceCall,
InvalidAuthAPIError,
InvalidEncryptionKeyAPIError,
LogLevel,
ReconnectLogic,
RequiresEncryptionAPIError,
SupportsResponseType,
UserService,
UserServiceArgType,
ZWaveProxyRequest,
@@ -44,7 +46,9 @@ from homeassistant.core import (
EventStateChangedData,
HomeAssistant,
ServiceCall,
ServiceResponse,
State,
SupportsResponse,
callback,
)
from homeassistant.exceptions import (
@@ -58,7 +62,7 @@ from homeassistant.helpers import (
device_registry as dr,
entity_registry as er,
issue_registry as ir,
json,
json as json_helper,
template,
)
from homeassistant.helpers.device_registry import format_mac
@@ -70,6 +74,7 @@ from homeassistant.helpers.issue_registry import (
)
from homeassistant.helpers.service import async_set_service_schema
from homeassistant.helpers.template import Template
from homeassistant.util.json import json_loads_object
from .bluetooth import async_connect_scanner
from .const import (
@@ -91,6 +96,7 @@ from .encryption_key_storage import async_get_encryption_key_storage
# Import config flow so that it's added to the registry
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
from .enum_mapper import EsphomeEnumMapper
DEVICE_CONFLICT_ISSUE_FORMAT = "device_conflict-{}"
UNPACK_UINT32_BE = struct.Struct(">I").unpack_from
@@ -367,7 +373,7 @@ class ESPHomeManager:
response_dict = {"response": action_response}
# JSON encode response data for ESPHome
response_data = json.json_bytes(response_dict)
response_data = json_helper.json_bytes(response_dict)
except (
ServiceNotFound,
@@ -1150,13 +1156,52 @@ ARG_TYPE_METADATA = {
}
@callback
def execute_service(
entry_data: RuntimeEntryData, service: UserService, call: ServiceCall
) -> None:
"""Execute a service on a node."""
async def execute_service(
entry_data: RuntimeEntryData,
service: UserService,
call: ServiceCall,
*,
supports_response: SupportsResponseType,
) -> ServiceResponse:
"""Execute a service on a node and optionally wait for response."""
# Determine if we should wait for a response
# NONE: fire and forget
# OPTIONAL/ONLY/STATUS: always wait for success/error confirmation
wait_for_response = supports_response != SupportsResponseType.NONE
if not wait_for_response:
# Fire and forget - no response expected
try:
await entry_data.client.execute_service(service, call.data)
except APIConnectionError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="action_call_failed",
translation_placeholders={
"call_name": service.name,
"device_name": entry_data.name,
"error": str(err),
},
) from err
else:
return None
# Determine if we need response_data from ESPHome
# ONLY: always need response_data
# OPTIONAL: only if caller requested it
# STATUS: never need response_data (just success/error)
need_response_data = supports_response == SupportsResponseType.ONLY or (
supports_response == SupportsResponseType.OPTIONAL and call.return_response
)
try:
entry_data.client.execute_service(service, call.data)
response: (
ExecuteServiceResponse | None
) = await entry_data.client.execute_service(
service,
call.data,
return_response=need_response_data,
)
except APIConnectionError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
@@ -1167,6 +1212,44 @@ def execute_service(
"error": str(err),
},
) from err
except TimeoutError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="action_call_timeout",
translation_placeholders={
"call_name": service.name,
"device_name": entry_data.name,
},
) from err
assert response is not None
if not response.success:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="action_call_failed",
translation_placeholders={
"call_name": service.name,
"device_name": entry_data.name,
"error": response.error_message,
},
)
# Parse and return response data as JSON if we requested it
if need_response_data and response.response_data:
try:
return json_loads_object(response.response_data)
except ValueError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="action_call_failed",
translation_placeholders={
"call_name": service.name,
"device_name": entry_data.name,
"error": f"Invalid JSON response: {err}",
},
) from err
return None
def build_service_name(device_info: EsphomeDeviceInfo, service: UserService) -> str:
@@ -1174,6 +1257,19 @@ def build_service_name(device_info: EsphomeDeviceInfo, service: UserService) ->
return f"{device_info.name.replace('-', '_')}_{service.name}"
# Map ESPHome SupportsResponseType to Home Assistant SupportsResponse
# STATUS (100) is ESPHome-specific: waits for success/error internally but
# doesn't return data to HA, so it maps to NONE from HA's perspective
_RESPONSE_TYPE_MAPPER = EsphomeEnumMapper[SupportsResponseType, SupportsResponse](
{
SupportsResponseType.NONE: SupportsResponse.NONE,
SupportsResponseType.OPTIONAL: SupportsResponse.OPTIONAL,
SupportsResponseType.ONLY: SupportsResponse.ONLY,
SupportsResponseType.STATUS: SupportsResponse.NONE,
}
)
@callback
def _async_register_service(
hass: HomeAssistant,
@@ -1205,11 +1301,21 @@ def _async_register_service(
"selector": metadata.selector,
}
# Get the supports_response from the service, defaulting to NONE
esphome_supports_response = service.supports_response or SupportsResponseType.NONE
ha_supports_response = _RESPONSE_TYPE_MAPPER.from_esphome(esphome_supports_response)
hass.services.async_register(
DOMAIN,
service_name,
partial(execute_service, entry_data, service),
partial(
execute_service,
entry_data,
service,
supports_response=esphome_supports_response,
),
vol.Schema(schema),
supports_response=ha_supports_response,
)
async_set_service_schema(
hass,

View File

@@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==42.10.0",
"aioesphomeapi==43.0.0",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==3.4.0"
],

View File

@@ -128,6 +128,9 @@
"action_call_failed": {
"message": "Failed to execute the action call {call_name} on {device_name}: {error}"
},
"action_call_timeout": {
"message": "Timeout waiting for response from action call {call_name} on {device_name}"
},
"error_communicating_with_device": {
"message": "Error communicating with the device {device_name}: {error}"
},

View File

@@ -42,6 +42,11 @@ class FressnapfTrackerDeviceTracker(FressnapfTrackerBaseEntity, TrackerEntity):
"""Return if entity is available."""
return super().available and self.coordinator.data.position is not None
@property
def entity_picture(self) -> str | None:
"""Return the entity picture url."""
return self.coordinator.data.icon
@property
def latitude(self) -> float | None:
"""Return latitude value of the device."""

View File

@@ -64,10 +64,11 @@ class PingDataICMPLib(PingData):
return
_LOGGER.debug(
"async_ping returned: reachable=%s sent=%i received=%s",
"async_ping returned: reachable=%s sent=%i received=%s loss=%s",
data.is_alive,
data.packets_sent,
data.packets_received,
data.packet_loss * 100,
)
self.is_alive = data.is_alive
@@ -80,6 +81,7 @@ class PingDataICMPLib(PingData):
"max": data.max_rtt,
"avg": data.avg_rtt,
"jitter": data.jitter,
"loss": data.packet_loss * 100,
}

View File

@@ -0,0 +1,9 @@
{
"entity": {
"sensor": {
"loss": {
"default": "mdi:alert-circle-outline"
}
}
}
}

View File

@@ -10,7 +10,7 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, UnitOfTime
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -82,6 +82,16 @@ SENSORS: tuple[PingSensorEntityDescription, ...] = (
value_fn=lambda result: result.data.get("jitter"),
has_fn=lambda result: "jitter" in result.data,
),
PingSensorEntityDescription(
key="loss",
translation_key="loss",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda result: result.data.get("loss"),
has_fn=lambda result: "loss" in result.data,
),
)

View File

@@ -22,6 +22,9 @@
"jitter": {
"name": "Jitter"
},
"loss": {
"name": "Packet loss"
},
"round_trip_time_avg": {
"name": "Round-trip time average"
},

View File

@@ -65,11 +65,9 @@ rules:
exception-translations: done
icon-translations: todo
reconfiguration-flow: todo
repair-issues:
status: todo
comment: The Cloud vs Local API warning should probably be a repair issue.
repair-issues: done
stale-devices: done
# Platinum
async-dependency: todo
async-dependency: done
inject-websession: done
strict-typing: done

View File

@@ -170,6 +170,9 @@ async def _async_setup_block_entry(
device_entry = dev_reg.async_get_device(
connections={(CONNECTION_NETWORK_MAC, dr.format_mac(entry.unique_id))},
)
# https://github.com/home-assistant/core/pull/48076
if device_entry and entry.entry_id not in device_entry.config_entries:
device_entry = None
sleep_period = entry.data.get(CONF_SLEEP_PERIOD)
runtime_data = entry.runtime_data
@@ -280,6 +283,9 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry)
device_entry = dev_reg.async_get_device(
connections={(CONNECTION_NETWORK_MAC, dr.format_mac(entry.unique_id))},
)
# https://github.com/home-assistant/core/pull/48076
if device_entry and entry.entry_id not in device_entry.config_entries:
device_entry = None
sleep_period = entry.data.get(CONF_SLEEP_PERIOD)
runtime_data = entry.runtime_data

View File

@@ -13,11 +13,6 @@ from aioshelly.ble.manufacturer_data import (
has_rpc_over_ble,
parse_shelly_manufacturer_data,
)
from aioshelly.ble.provisioning import (
async_provision_wifi,
async_scan_wifi_networks,
ble_rpc_device,
)
from aioshelly.block_device import BlockDevice
from aioshelly.common import ConnectionOptions, get_info
from aioshelly.const import BLOCK_GENERATIONS, DEFAULT_HTTP_PORT, RPC_GENERATIONS
@@ -30,6 +25,7 @@ from aioshelly.exceptions import (
RpcCallError,
)
from aioshelly.rpc_device import RpcDevice
from aioshelly.rpc_device.models import ShellyWiFiNetwork
from aioshelly.zeroconf import async_discover_devices, async_lookup_device_by_name
from bleak.backends.device import BLEDevice
import voluptuous as vol
@@ -118,31 +114,6 @@ MANUAL_ENTRY_STRING = "manual"
DISCOVERY_SOURCES = {SOURCE_BLUETOOTH, SOURCE_ZEROCONF}
async def async_get_ip_from_ble(ble_device: BLEDevice) -> str | None:
"""Get device IP address via BLE after WiFi provisioning.
Args:
ble_device: BLE device to query
Returns:
IP address string if available, None otherwise
"""
try:
async with ble_rpc_device(ble_device) as device:
await device.update_status()
if (
(wifi := device.status.get("wifi"))
and isinstance(wifi, dict)
and (ip := wifi.get("sta_ip"))
):
return cast(str, ip)
return None
except (DeviceConnectionError, RpcCallError) as err:
LOGGER.debug("Failed to get IP via BLE: %s", err)
return None
# BLE provisioning flow steps that are in the finishing state
# Used to determine if a BLE flow should be aborted when zeroconf discovers the device
BLUETOOTH_FINISHING_STEPS = {"do_provision", "provision_done"}
@@ -244,13 +215,14 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
device_info: dict[str, Any] = {}
ble_device: BLEDevice | None = None
device_name: str = ""
wifi_networks: list[dict[str, Any]] = []
wifi_networks: list[ShellyWiFiNetwork] = []
selected_ssid: str = ""
_provision_task: asyncio.Task | None = None
_provision_result: ConfigFlowResult | None = None
disable_ap_after_provision: bool = True
disable_ble_rpc_after_provision: bool = True
_discovered_devices: dict[str, DiscoveredDeviceZeroconf | DiscoveredDeviceBluetooth]
_ble_rpc_device: RpcDevice | None = None
@staticmethod
def _get_name_from_mac_and_ble_model(
@@ -299,6 +271,73 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
return mac, device_name
async def _async_ensure_ble_connected(self) -> RpcDevice:
"""Ensure BLE RPC device is connected, reconnecting if needed.
Maintains a persistent BLE connection across config flow steps to avoid
the overhead of reconnecting between WiFi scan and provisioning steps.
Returns:
Connected RpcDevice instance
Raises:
DeviceConnectionError: If connection fails
RpcCallError: If ping fails after connection
"""
if TYPE_CHECKING:
assert self.ble_device is not None
if self._ble_rpc_device is not None and self._ble_rpc_device.connected:
# Ping to verify connection is still alive
try:
await self._ble_rpc_device.update_status()
except (DeviceConnectionError, RpcCallError):
# Connection dropped, need to reconnect
LOGGER.debug("BLE connection lost, reconnecting")
await self._async_disconnect_ble()
else:
return self._ble_rpc_device
# Create new connection
LOGGER.debug("Creating new BLE RPC connection to %s", self.ble_device.address)
options = ConnectionOptions(ble_device=self.ble_device)
self._ble_rpc_device = await RpcDevice.create(
aiohttp_session=None, ws_context=None, ip_or_options=options
)
await self._ble_rpc_device.initialize()
return self._ble_rpc_device
async def _async_disconnect_ble(self) -> None:
"""Disconnect and cleanup BLE RPC device."""
if self._ble_rpc_device is not None:
await self._ble_rpc_device.shutdown()
self._ble_rpc_device = None
async def _async_get_ip_from_ble(self) -> str | None:
"""Get device IP address via BLE after WiFi provisioning.
Uses the persistent BLE connection to get the device's sta_ip from status.
Returns:
IP address string if available, None otherwise
"""
try:
device = await self._async_ensure_ble_connected()
await device.update_status()
except (DeviceConnectionError, RpcCallError) as err:
LOGGER.debug("Failed to get IP via BLE: %s", err)
return None
if (
(wifi := device.status.get("wifi"))
and isinstance(wifi, dict)
and (ip := wifi.get("sta_ip"))
):
return cast(str, ip)
return None
async def _async_discover_zeroconf_devices(
self,
) -> dict[str, DiscoveredDeviceZeroconf]:
@@ -736,20 +775,21 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
password = user_input[CONF_PASSWORD]
return await self.async_step_do_provision({"password": password})
# Scan for WiFi networks via BLE
if TYPE_CHECKING:
assert self.ble_device is not None
# Scan for WiFi networks via BLE using persistent connection
try:
self.wifi_networks = await async_scan_wifi_networks(self.ble_device)
device = await self._async_ensure_ble_connected()
self.wifi_networks = await device.wifi_scan()
except (DeviceConnectionError, RpcCallError) as err:
LOGGER.debug("Failed to scan WiFi networks via BLE: %s", err)
# "Writing is not permitted" error means device rejects BLE writes
# and BLE provisioning is disabled - user must use Shelly app
if "not permitted" in str(err):
await self._async_disconnect_ble()
return self.async_abort(reason="ble_not_permitted")
return await self.async_step_wifi_scan_failed()
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception during WiFi scan")
await self._async_disconnect_ble()
return self.async_abort(reason="unknown")
# Sort by RSSI (strongest signal first - higher/less negative values first)
@@ -870,17 +910,21 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
Returns the flow result to be stored in self._provision_result, or None if failed.
"""
# Provision WiFi via BLE
if TYPE_CHECKING:
assert self.ble_device is not None
# Provision WiFi via BLE using persistent connection
try:
await async_provision_wifi(self.ble_device, self.selected_ssid, password)
device = await self._async_ensure_ble_connected()
await device.wifi_setconfig(
sta_ssid=self.selected_ssid,
sta_password=password,
sta_enable=True,
)
except (DeviceConnectionError, RpcCallError) as err:
LOGGER.debug("Failed to provision WiFi via BLE: %s", err)
# BLE connection/communication failed - allow retry from network selection
return None
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception during WiFi provisioning")
await self._async_disconnect_ble()
return self.async_abort(reason="unknown")
LOGGER.debug(
@@ -918,7 +962,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
LOGGER.debug(
"Active lookup failed, trying to get IP address via BLE as fallback"
)
if ip := await async_get_ip_from_ble(self.ble_device):
if ip := await self._async_get_ip_from_ble():
LOGGER.debug("Got IP %s from BLE, using it", ip)
state.host = ip
state.port = DEFAULT_HTTP_PORT
@@ -995,12 +1039,17 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
if TYPE_CHECKING:
assert mac is not None
async with self._async_provision_context(mac) as state:
self._provision_result = (
await self._async_provision_wifi_and_wait_for_zeroconf(
mac, password, state
try:
async with self._async_provision_context(mac) as state:
self._provision_result = (
await self._async_provision_wifi_and_wait_for_zeroconf(
mac, password, state
)
)
)
finally:
# Always disconnect BLE after provisioning attempt completes
# We either succeeded (and will use IP now) or failed (and user will retry)
await self._async_disconnect_ble()
async def async_step_do_provision(
self, user_input: dict[str, Any] | None = None
@@ -1219,6 +1268,17 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
"""Get info from shelly device."""
return await get_info(async_get_clientsession(self.hass), host, port=port)
@callback
def async_remove(self) -> None:
"""Handle flow removal - cleanup BLE connection."""
super().async_remove()
if self._ble_rpc_device is not None:
# Schedule cleanup as background task since async_remove is sync
self.hass.async_create_background_task(
self._async_disconnect_ble(),
name="shelly_config_flow_ble_cleanup",
)
@staticmethod
@callback
def async_get_options_flow(config_entry: ShellyConfigEntry) -> OptionsFlowHandler:

View File

@@ -19,6 +19,7 @@ from homeassistant.components.light import (
LightEntityDescription,
LightEntityFeature,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.color import rgb_hex_to_rgb_list
@@ -117,6 +118,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiLightEntityDescription, ...] = (
UnifiLightEntityDescription[Devices, Device](
key="LED control",
translation_key="led_control",
entity_category=EntityCategory.CONFIG,
allowed_fn=lambda hub, obj_id: True,
api_handler_fn=lambda api: api.devices,
available_fn=async_device_available_fn,

View File

@@ -31,6 +31,7 @@ from homeassistant.const import (
CONF_VERIFY_SSL,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import selector
from homeassistant.helpers.aiohttp_client import (
async_create_clientsession,
async_get_clientsession,
@@ -56,15 +57,113 @@ from .const import (
)
from .data import UFPConfigEntry, async_last_update_was_successful
from .discovery import async_start_discovery
from .utils import _async_resolve, _async_short_mac, _async_unifi_mac_from_hass
from .utils import (
_async_resolve,
_async_short_mac,
_async_unifi_mac_from_hass,
async_create_api_client,
)
_LOGGER = logging.getLogger(__name__)
def _filter_empty_credentials(user_input: dict[str, Any]) -> dict[str, Any]:
"""Filter out empty credential fields to preserve existing values."""
return {k: v for k, v in user_input.items() if v not in (None, "")}
def _normalize_port(data: dict[str, Any]) -> dict[str, Any]:
"""Ensure port is stored as int (NumberSelector returns float)."""
return {**data, CONF_PORT: int(data.get(CONF_PORT, DEFAULT_PORT))}
def _build_data_without_credentials(entry_data: Mapping[str, Any]) -> dict[str, Any]:
"""Build form data from existing config entry, excluding sensitive credentials."""
return {
CONF_HOST: entry_data[CONF_HOST],
CONF_PORT: entry_data[CONF_PORT],
CONF_VERIFY_SSL: entry_data[CONF_VERIFY_SSL],
CONF_USERNAME: entry_data[CONF_USERNAME],
}
async def _async_clear_session_if_credentials_changed(
hass: HomeAssistant,
entry: UFPConfigEntry,
new_data: Mapping[str, Any],
) -> None:
"""Clear stored session if credentials have changed to force fresh authentication."""
existing_data = entry.data
if existing_data.get(CONF_USERNAME) != new_data.get(
CONF_USERNAME
) or existing_data.get(CONF_PASSWORD) != new_data.get(CONF_PASSWORD):
_LOGGER.debug("Credentials changed, clearing stored session")
protect = async_create_api_client(hass, entry)
try:
await protect.clear_session()
except Exception as ex: # noqa: BLE001
_LOGGER.debug("Failed to clear session, continuing anyway: %s", ex)
ENTRY_FAILURE_STATES = (
ConfigEntryState.SETUP_ERROR,
ConfigEntryState.SETUP_RETRY,
)
# Selectors for config flow form fields
_TEXT_SELECTOR = selector.TextSelector()
_PASSWORD_SELECTOR = selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
)
_PORT_SELECTOR = selector.NumberSelector(
selector.NumberSelectorConfig(
mode=selector.NumberSelectorMode.BOX, min=1, max=65535
)
)
_BOOL_SELECTOR = selector.BooleanSelector()
def _build_schema(
*,
include_host: bool = True,
include_connection: bool = True,
credentials_optional: bool = False,
) -> vol.Schema:
"""Build a config flow schema.
Args:
include_host: Include host field (False when host comes from discovery).
include_connection: Include port/verify_ssl fields.
credentials_optional: Credentials optional (True to keep existing values).
"""
req, opt = vol.Required, vol.Optional
cred_key = opt if credentials_optional else req
schema: dict[vol.Marker, selector.Selector] = {}
if include_host:
schema[req(CONF_HOST)] = _TEXT_SELECTOR
if include_connection:
schema[req(CONF_PORT, default=DEFAULT_PORT)] = _PORT_SELECTOR
schema[req(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL)] = _BOOL_SELECTOR
schema[req(CONF_USERNAME)] = _TEXT_SELECTOR
schema[cred_key(CONF_PASSWORD)] = _PASSWORD_SELECTOR
schema[cred_key(CONF_API_KEY)] = _PASSWORD_SELECTOR
return vol.Schema(schema)
# Schemas for different flow contexts
# User flow: all fields required
CONFIG_SCHEMA = _build_schema()
# Reconfigure flow: keep existing credentials if not provided
RECONFIGURE_SCHEMA = _build_schema(credentials_optional=True)
# Discovery flow: host comes from discovery, user sets port/ssl
DISCOVERY_SCHEMA = _build_schema(include_host=False)
# Reauth flow: only credentials, connection settings preserved
REAUTH_SCHEMA = _build_schema(
include_host=False, include_connection=False, credentials_optional=True
)
async def async_local_user_documentation_url(hass: HomeAssistant) -> str:
"""Get the documentation url for creating a local user."""
@@ -178,19 +277,40 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN):
"""Confirm discovery."""
errors: dict[str, str] = {}
discovery_info = self._discovered_device
form_data = {
CONF_HOST: discovery_info["direct_connect_domain"]
or discovery_info["source_ip"],
CONF_PORT: DEFAULT_PORT,
CONF_VERIFY_SSL: bool(discovery_info["direct_connect_domain"]),
CONF_USERNAME: "",
CONF_PASSWORD: "",
}
if user_input is not None:
user_input[CONF_PORT] = DEFAULT_PORT
# Merge user input with discovery info
merged_input = {**form_data, **user_input}
nvr_data = None
if discovery_info["direct_connect_domain"]:
user_input[CONF_HOST] = discovery_info["direct_connect_domain"]
user_input[CONF_VERIFY_SSL] = True
nvr_data, errors = await self._async_get_nvr_data(user_input)
merged_input[CONF_HOST] = discovery_info["direct_connect_domain"]
merged_input[CONF_VERIFY_SSL] = True
nvr_data, errors = await self._async_get_nvr_data(merged_input)
if not nvr_data or errors:
user_input[CONF_HOST] = discovery_info["source_ip"]
user_input[CONF_VERIFY_SSL] = False
nvr_data, errors = await self._async_get_nvr_data(user_input)
merged_input[CONF_HOST] = discovery_info["source_ip"]
merged_input[CONF_VERIFY_SSL] = False
nvr_data, errors = await self._async_get_nvr_data(merged_input)
if nvr_data and not errors:
return self._async_create_entry(nvr_data.display_name, user_input)
return self._async_create_entry(nvr_data.display_name, merged_input)
# Preserve user input for form re-display, but keep discovery info
form_data = {
CONF_HOST: merged_input[CONF_HOST],
CONF_PORT: merged_input[CONF_PORT],
CONF_VERIFY_SSL: merged_input[CONF_VERIFY_SSL],
CONF_USERNAME: user_input.get(CONF_USERNAME, ""),
CONF_PASSWORD: user_input.get(CONF_PASSWORD, ""),
}
if CONF_API_KEY in user_input:
form_data[CONF_API_KEY] = user_input[CONF_API_KEY]
placeholders = {
"name": discovery_info["hostname"]
@@ -199,7 +319,6 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN):
"ip_address": discovery_info["source_ip"],
}
self.context["title_placeholders"] = placeholders
user_input = user_input or {}
return self.async_show_form(
step_id="discovery_confirm",
description_placeholders={
@@ -208,14 +327,8 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN):
self.hass
),
},
data_schema=vol.Schema(
{
vol.Required(
CONF_USERNAME, default=user_input.get(CONF_USERNAME)
): str,
vol.Required(CONF_PASSWORD): str,
vol.Required(CONF_API_KEY): str,
}
data_schema=self.add_suggested_values_to_schema(
DISCOVERY_SCHEMA, form_data
),
errors=errors,
)
@@ -232,7 +345,7 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN):
def _async_create_entry(self, title: str, data: dict[str, Any]) -> ConfigFlowResult:
return self.async_create_entry(
title=title,
data={**data, CONF_ID: title},
data={**_normalize_port(data), CONF_ID: title},
options={
CONF_DISABLE_RTSP: False,
CONF_ALL_UPDATES: False,
@@ -251,7 +364,7 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN):
public_api_session = async_get_clientsession(self.hass)
host = user_input[CONF_HOST]
port = user_input.get(CONF_PORT, DEFAULT_PORT)
port = int(user_input.get(CONF_PORT, DEFAULT_PORT))
verify_ssl = user_input.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL)
protect = ProtectApiClient(
@@ -261,7 +374,7 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN):
port=port,
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
api_key=user_input[CONF_API_KEY],
api_key=user_input.get(CONF_API_KEY, ""),
verify_ssl=verify_ssl,
cache_dir=Path(self.hass.config.path(STORAGE_DIR, "unifiprotect")),
config_dir=Path(self.hass.config.path(STORAGE_DIR, "unifiprotect")),
@@ -290,14 +403,17 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN):
auth_user = bootstrap.users.get(bootstrap.auth_user_id)
if auth_user and auth_user.cloud_account:
errors["base"] = "cloud_user"
try:
await protect.get_meta_info()
except NotAuthorized as ex:
_LOGGER.debug(ex)
errors[CONF_API_KEY] = "invalid_auth"
except ClientError as ex:
_LOGGER.error(ex)
errors["base"] = "cannot_connect"
# Only validate API key if bootstrap succeeded
if nvr_data and not errors:
try:
await protect.get_meta_info()
except NotAuthorized as ex:
_LOGGER.debug(ex)
errors[CONF_API_KEY] = "invalid_auth"
except ClientError as ex:
_LOGGER.error(ex)
errors["base"] = "cannot_connect"
return nvr_data, errors
@@ -313,16 +429,27 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN):
"""Confirm reauth."""
errors: dict[str, str] = {}
# prepopulate fields
reauth_entry = self._get_reauth_entry()
form_data = {**reauth_entry.data}
form_data = _build_data_without_credentials(reauth_entry.data)
if user_input is not None:
form_data.update(user_input)
# Merge with existing config - empty credentials keep existing values
merged_input = {
**reauth_entry.data,
**_filter_empty_credentials(user_input),
}
# Clear stored session if credentials changed to force fresh authentication
await _async_clear_session_if_credentials_changed(
self.hass, reauth_entry, merged_input
)
# validate login data
_, errors = await self._async_get_nvr_data(form_data)
_, errors = await self._async_get_nvr_data(merged_input)
if not errors:
return self.async_update_reload_and_abort(reauth_entry, data=form_data)
return self.async_update_reload_and_abort(
reauth_entry, data=_normalize_port(merged_input)
)
self.context["title_placeholders"] = {
"name": reauth_entry.title,
@@ -335,14 +462,58 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN):
self.hass
),
},
data_schema=vol.Schema(
{
vol.Required(
CONF_USERNAME, default=form_data.get(CONF_USERNAME)
): str,
vol.Required(CONF_PASSWORD): str,
vol.Required(CONF_API_KEY): str,
}
data_schema=self.add_suggested_values_to_schema(REAUTH_SCHEMA, form_data),
errors=errors,
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration of the integration."""
errors: dict[str, str] = {}
reconfigure_entry = self._get_reconfigure_entry()
form_data = _build_data_without_credentials(reconfigure_entry.data)
if user_input is not None:
# Merge with existing config - empty credentials keep existing values
merged_input = {
**reconfigure_entry.data,
**_filter_empty_credentials(user_input),
}
# Clear stored session if credentials changed to force fresh authentication
await _async_clear_session_if_credentials_changed(
self.hass, reconfigure_entry, merged_input
)
# validate login data
nvr_data, errors = await self._async_get_nvr_data(merged_input)
if nvr_data and not errors:
new_unique_id = _async_unifi_mac_from_hass(nvr_data.mac)
_LOGGER.debug(
"Reconfigure: Current unique_id=%s, NVR MAC=%s, formatted=%s",
reconfigure_entry.unique_id,
nvr_data.mac,
new_unique_id,
)
await self.async_set_unique_id(new_unique_id)
self._abort_if_unique_id_mismatch(reason="wrong_nvr")
return self.async_update_reload_and_abort(
reconfigure_entry,
data=_normalize_port(merged_input),
)
return self.async_show_form(
step_id="reconfigure",
description_placeholders={
"local_user_documentation_url": await async_local_user_documentation_url(
self.hass
),
},
data_schema=self.add_suggested_values_to_schema(
RECONFIGURE_SCHEMA, form_data
),
errors=errors,
)
@@ -362,7 +533,6 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN):
return self._async_create_entry(nvr_data.display_name, user_input)
user_input = user_input or {}
return self.async_show_form(
step_id="user",
description_placeholders={
@@ -370,23 +540,7 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN):
self.hass
)
},
data_schema=vol.Schema(
{
vol.Required(CONF_HOST, default=user_input.get(CONF_HOST)): str,
vol.Required(
CONF_PORT, default=user_input.get(CONF_PORT, DEFAULT_PORT)
): int,
vol.Required(
CONF_VERIFY_SSL,
default=user_input.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL),
): bool,
vol.Required(
CONF_USERNAME, default=user_input.get(CONF_USERNAME)
): str,
vol.Required(CONF_PASSWORD): str,
vol.Required(CONF_API_KEY): str,
}
),
data_schema=self.add_suggested_values_to_schema(CONFIG_SCHEMA, user_input),
errors=errors,
)

View File

@@ -3,7 +3,9 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"discovery_started": "Discovery started",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"wrong_nvr": "Connected to a different NVR than expected. If you replaced your hardware, please remove the old integration and add it again."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -17,12 +19,16 @@
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
"port": "[%key:common::config_flow::data::port%]",
"username": "[%key:common::config_flow::data::username%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"api_key": "[%key:component::unifiprotect::config::step::user::data_description::api_key%]",
"password": "[%key:component::unifiprotect::config::step::user::data_description::password%]",
"username": "[%key:component::unifiprotect::config::step::user::data_description::username%]"
"port": "[%key:component::unifiprotect::config::step::user::data_description::port%]",
"username": "[%key:component::unifiprotect::config::step::user::data_description::username%]",
"verify_ssl": "[%key:component::unifiprotect::config::step::user::data_description::verify_ssl%]"
},
"description": "Do you want to set up {name} ({ip_address})? You will need a local user created in your UniFi OS Console to log in with. Ubiquiti Cloud users will not work. For more information: {local_user_documentation_url}",
"title": "UniFi Protect discovered"
@@ -30,20 +36,36 @@
"reauth_confirm": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
"host": "IP/Host of UniFi Protect server",
"password": "[%key:common::config_flow::data::password%]",
"port": "[%key:common::config_flow::data::port%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"api_key": "[%key:component::unifiprotect::config::step::user::data_description::api_key%]",
"host": "[%key:component::unifiprotect::config::step::user::data_description::host%]",
"password": "[%key:component::unifiprotect::config::step::user::data_description::password%]",
"port": "[%key:component::unifiprotect::config::step::user::data_description::port%]",
"api_key": "API key for your local user account. Leave empty to keep your existing API key.",
"password": "Password for your local user account. Leave empty to keep your existing password.",
"username": "[%key:component::unifiprotect::config::step::user::data_description::username%]"
},
"description": "Your credentials or API key seem to be missing or invalid. For instructions on how to create a local user or generate a new API key, please refer to the documentation: {local_user_documentation_url}",
"title": "UniFi Protect reauth"
"description": "Your credentials or API key seem to be missing or invalid. Leave password and API key empty to keep your existing credentials. For more information: {local_user_documentation_url}",
"title": "Reauth UniFi Protect"
},
"reconfigure": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
"host": "[%key:common::config_flow::data::host%]",
"password": "[%key:common::config_flow::data::password%]",
"port": "[%key:common::config_flow::data::port%]",
"username": "[%key:common::config_flow::data::username%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"api_key": "[%key:component::unifiprotect::config::step::reauth_confirm::data_description::api_key%]",
"host": "[%key:component::unifiprotect::config::step::user::data_description::host%]",
"password": "[%key:component::unifiprotect::config::step::reauth_confirm::data_description::password%]",
"port": "[%key:component::unifiprotect::config::step::user::data_description::port%]",
"username": "[%key:component::unifiprotect::config::step::user::data_description::username%]",
"verify_ssl": "[%key:component::unifiprotect::config::step::user::data_description::verify_ssl%]"
},
"description": "Update the configuration for your UniFi Protect device. Leave password and API key empty to keep your existing credentials. For more information: {local_user_documentation_url}",
"title": "Reconfigure UniFi Protect"
},
"user": {
"data": {

View File

@@ -13,5 +13,5 @@
"documentation": "https://www.home-assistant.io/integrations/yale",
"iot_class": "cloud_push",
"loggers": ["socketio", "engineio", "yalexs"],
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.2.1"]
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.2.2"]
}

View File

@@ -52,6 +52,7 @@ class YaleXSBLEBaseLock(YALEXSBLEEntity, LockEntity):
elif lock_state in (
LockStatus.UNKNOWN_01,
LockStatus.UNKNOWN_06,
LockStatus.JAMMED,
):
self._attr_is_jammed = True
elif lock_state is LockStatus.UNKNOWN:

View File

@@ -12,5 +12,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/yalexs_ble",
"iot_class": "local_push",
"requirements": ["yalexs-ble==3.2.1"]
"requirements": ["yalexs-ble==3.2.2"]
}

4
requirements_all.txt generated
View File

@@ -252,7 +252,7 @@ aioelectricitymaps==1.1.1
aioemonitor==1.0.5
# homeassistant.components.esphome
aioesphomeapi==42.10.0
aioesphomeapi==43.0.0
# homeassistant.components.matrix
# homeassistant.components.slack
@@ -3215,7 +3215,7 @@ yalesmartalarmclient==0.4.3
# homeassistant.components.august
# homeassistant.components.yale
# homeassistant.components.yalexs_ble
yalexs-ble==3.2.1
yalexs-ble==3.2.2
# homeassistant.components.august
# homeassistant.components.yale

View File

@@ -243,7 +243,7 @@ aioelectricitymaps==1.1.1
aioemonitor==1.0.5
# homeassistant.components.esphome
aioesphomeapi==42.10.0
aioesphomeapi==43.0.0
# homeassistant.components.matrix
# homeassistant.components.slack
@@ -2679,7 +2679,7 @@ yalesmartalarmclient==0.4.3
# homeassistant.components.august
# homeassistant.components.yale
# homeassistant.components.yalexs_ble
yalexs-ble==3.2.1
yalexs-ble==3.2.2
# homeassistant.components.august
# homeassistant.components.yale

View File

@@ -12,7 +12,13 @@ from pyairobotrest.models import (
import pytest
from homeassistant.components.airobot.const import DOMAIN
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD, CONF_USERNAME
from homeassistant.const import (
CONF_HOST,
CONF_MAC,
CONF_PASSWORD,
CONF_USERNAME,
Platform,
)
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
@@ -105,16 +111,24 @@ def mock_config_entry() -> MockConfigEntry:
)
@pytest.fixture
def platforms() -> list[Platform]:
"""Fixture to specify platforms to test."""
return [Platform.CLIMATE, Platform.SENSOR]
@pytest.fixture
async def init_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_airobot_client: AsyncMock,
platforms: list[Platform],
) -> MockConfigEntry:
"""Set up the Airobot integration for testing."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
with patch("homeassistant.components.airobot.PLATFORMS", platforms):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry

View File

@@ -0,0 +1,220 @@
# serializer version: 1
# name: test_sensors[sensor.test_thermostat_air_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.test_thermostat_air_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Air temperature',
'platform': 'airobot',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'air_temperature',
'unique_id': 'T01A1B2C3_air_temperature',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensors[sensor.test_thermostat_air_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Test Thermostat Air temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.test_thermostat_air_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '22.0',
})
# ---
# name: test_sensors[sensor.test_thermostat_error_count-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.test_thermostat_error_count',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Error count',
'platform': 'airobot',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'errors',
'unique_id': 'T01A1B2C3_errors',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[sensor.test_thermostat_error_count-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Test Thermostat Error count',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'context': <ANY>,
'entity_id': 'sensor.test_thermostat_error_count',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0',
})
# ---
# name: test_sensors[sensor.test_thermostat_heating_uptime-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.test_thermostat_heating_uptime',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfTime.HOURS: 'h'>,
}),
}),
'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
'original_icon': None,
'original_name': 'Heating uptime',
'platform': 'airobot',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'heating_uptime',
'unique_id': 'T01A1B2C3_heating_uptime',
'unit_of_measurement': <UnitOfTime.HOURS: 'h'>,
})
# ---
# name: test_sensors[sensor.test_thermostat_heating_uptime-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'duration',
'friendly_name': 'Test Thermostat Heating uptime',
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
'unit_of_measurement': <UnitOfTime.HOURS: 'h'>,
}),
'context': <ANY>,
'entity_id': 'sensor.test_thermostat_heating_uptime',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1.38888888888889',
})
# ---
# name: test_sensors[sensor.test_thermostat_humidity-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.test_thermostat_humidity',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.HUMIDITY: 'humidity'>,
'original_icon': None,
'original_name': 'Humidity',
'platform': 'airobot',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'T01A1B2C3_humidity',
'unit_of_measurement': '%',
})
# ---
# name: test_sensors[sensor.test_thermostat_humidity-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'humidity',
'friendly_name': 'Test Thermostat Humidity',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.test_thermostat_humidity',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '45.0',
})
# ---

View File

@@ -17,7 +17,7 @@ from homeassistant.components.climate import (
SERVICE_SET_PRESET_MODE,
SERVICE_SET_TEMPERATURE,
)
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
import homeassistant.helpers.entity_registry as er
@@ -25,12 +25,19 @@ import homeassistant.helpers.entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
@pytest.fixture
def platforms() -> list[Platform]:
"""Fixture to specify platforms to test."""
return [Platform.CLIMATE]
@pytest.mark.usefixtures("init_integration")
async def test_climate_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
platforms: list[Platform],
) -> None:
"""Test climate entities."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)

View File

@@ -0,0 +1,38 @@
"""Tests for the Airobot sensor platform."""
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
@pytest.fixture
def platforms() -> list[Platform]:
"""Fixture to specify platforms to test."""
return [Platform.SENSOR]
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
async def test_sensors(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the sensor entities."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
async def test_sensor_availability_without_optional_sensors(
hass: HomeAssistant,
) -> None:
"""Test sensors are not created when optional hardware is not present."""
# Default mock has no floor sensor, CO2, or AQI - they should not be created
assert hass.states.get("sensor.test_thermostat_floor_temperature") is None
assert hass.states.get("sensor.test_thermostat_carbon_dioxide") is None
assert hass.states.get("sensor.test_thermostat_air_quality_index") is None

View File

@@ -12,12 +12,14 @@ from aioesphomeapi import (
AreaInfo,
DeviceInfo,
EncryptionPlaintextAPIError,
ExecuteServiceResponse,
HomeassistantServiceCall,
InvalidAuthAPIError,
InvalidEncryptionKeyAPIError,
LogLevel,
RequiresEncryptionAPIError,
SubDeviceInfo,
SupportsResponseType,
UserService,
UserServiceArg,
UserServiceArgType,
@@ -49,7 +51,7 @@ from homeassistant.const import (
CONF_PORT,
EVENT_HOMEASSISTANT_CLOSE,
)
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import (
@@ -1456,7 +1458,7 @@ async def test_esphome_user_service_fails(
await hass.async_block_till_done()
assert hass.services.has_service(DOMAIN, "with_dash_simple_service")
mock_client.execute_service = Mock(side_effect=APIConnectionError("fail"))
mock_client.execute_service = AsyncMock(side_effect=APIConnectionError("fail"))
with pytest.raises(HomeAssistantError) as exc:
await hass.services.async_call(
DOMAIN, "with_dash_simple_service", {"arg1": True}, blocking=True
@@ -2812,3 +2814,462 @@ async def test_no_zwave_proxy_subscribe_without_feature_flags(
# Verify subscribe_zwave_proxy_request was NOT called
mock_client.subscribe_zwave_proxy_request.assert_not_called()
async def test_execute_service_response_type_none(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test execute_service with SupportsResponseType.NONE (fire and forget)."""
service = UserService(
name="fire_forget_service",
key=1,
args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)],
supports_response=SupportsResponseType.NONE,
)
# For NONE type, no response is expected
mock_client.execute_service = AsyncMock(return_value=None)
await mock_esphome_device(
mock_client=mock_client,
user_service=[service],
device_info={"name": "test"},
)
await hass.async_block_till_done()
assert hass.services.has_service(DOMAIN, "test_fire_forget_service")
# Call the service - should be fire and forget
await hass.services.async_call(
DOMAIN, "test_fire_forget_service", {"arg1": True}, blocking=True
)
await hass.async_block_till_done()
# Verify execute_service was called without extra kwargs (fire and forget)
mock_client.execute_service.assert_called_once()
call_args = mock_client.execute_service.call_args
assert call_args[0][1] == {"arg1": True}
# Fire and forget - no return_response or other kwargs
assert call_args[1] == {}
async def test_execute_service_response_type_status(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test execute_service with SupportsResponseType.STATUS."""
service = UserService(
name="status_service",
key=1,
args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)],
supports_response=SupportsResponseType.STATUS,
)
# Set up mock response
mock_client.execute_service = AsyncMock(
return_value=ExecuteServiceResponse(
call_id=1,
success=True,
error_message="",
response_data=b"",
)
)
await mock_esphome_device(
mock_client=mock_client,
user_service=[service],
device_info={"name": "test"},
)
await hass.async_block_till_done()
# Call the service - should wait for response but not return data
# Note: STATUS maps to SupportsResponse.NONE so we can't use return_response=True
await hass.services.async_call(
DOMAIN, "test_status_service", {"arg1": True}, blocking=True
)
await hass.async_block_till_done()
# Verify return_response was False (STATUS doesn't need response_data)
call_args = mock_client.execute_service.call_args
assert call_args[1].get("return_response") is False
async def test_execute_service_response_type_optional_without_return(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test execute_service with SupportsResponseType.OPTIONAL when caller doesn't request response."""
service = UserService(
name="optional_service",
key=1,
args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)],
supports_response=SupportsResponseType.OPTIONAL,
)
# Set up mock response
mock_client.execute_service = AsyncMock(
return_value=ExecuteServiceResponse(
call_id=1,
success=True,
error_message="",
response_data=b'{"result": "data"}',
)
)
await mock_esphome_device(
mock_client=mock_client,
user_service=[service],
device_info={"name": "test"},
)
await hass.async_block_till_done()
# Call without return_response - should still wait but not return data
result = await hass.services.async_call(
DOMAIN, "test_optional_service", {"arg1": True}, blocking=True
)
await hass.async_block_till_done()
assert result is None
# Verify return_response was False (caller didn't request it)
call_args = mock_client.execute_service.call_args
assert call_args[1].get("return_response") is False
async def test_execute_service_response_type_optional_with_return(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test execute_service with SupportsResponseType.OPTIONAL when caller requests response."""
service = UserService(
name="optional_service",
key=1,
args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)],
supports_response=SupportsResponseType.OPTIONAL,
)
# Set up mock response with data
mock_client.execute_service = AsyncMock(
return_value=ExecuteServiceResponse(
call_id=1,
success=True,
error_message="",
response_data=b'{"result": "data"}',
)
)
await mock_esphome_device(
mock_client=mock_client,
user_service=[service],
device_info={"name": "test"},
)
await hass.async_block_till_done()
# Call with return_response=True
result = await hass.services.async_call(
DOMAIN,
"test_optional_service",
{"arg1": True},
blocking=True,
return_response=True,
)
await hass.async_block_till_done()
# Should return parsed JSON data
assert result == {"result": "data"}
# Verify return_response was True
call_args = mock_client.execute_service.call_args
assert call_args[1].get("return_response") is True
async def test_execute_service_response_type_only(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test execute_service with SupportsResponseType.ONLY."""
service = UserService(
name="only_service",
key=1,
args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)],
supports_response=SupportsResponseType.ONLY,
)
# Set up mock response
mock_client.execute_service = AsyncMock(
return_value=ExecuteServiceResponse(
call_id=1,
success=True,
error_message="",
response_data=b'{"status": "ok", "value": 42}',
)
)
await mock_esphome_device(
mock_client=mock_client,
user_service=[service],
device_info={"name": "test"},
)
await hass.async_block_till_done()
# Call the service - ONLY type always returns data
result = await hass.services.async_call(
DOMAIN, "test_only_service", {"arg1": True}, blocking=True, return_response=True
)
await hass.async_block_till_done()
assert result == {"status": "ok", "value": 42}
# Verify return_response was True
call_args = mock_client.execute_service.call_args
assert call_args[1].get("return_response") is True
async def test_execute_service_timeout(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test execute_service timeout handling."""
service = UserService(
name="slow_service",
key=1,
args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)],
supports_response=SupportsResponseType.STATUS,
)
# Mock execute_service to raise TimeoutError
mock_client.execute_service = AsyncMock(side_effect=TimeoutError())
await mock_esphome_device(
mock_client=mock_client,
user_service=[service],
device_info={"name": "test"},
)
await hass.async_block_till_done()
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
DOMAIN, "test_slow_service", {"arg1": True}, blocking=True
)
assert "Timeout" in str(exc_info.value)
async def test_execute_service_connection_error(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test execute_service connection error handling."""
service = UserService(
name="error_service",
key=1,
args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)],
supports_response=SupportsResponseType.NONE,
)
mock_client.execute_service = AsyncMock(
side_effect=APIConnectionError("Connection lost")
)
await mock_esphome_device(
mock_client=mock_client,
user_service=[service],
device_info={"name": "test"},
)
await hass.async_block_till_done()
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
DOMAIN, "test_error_service", {"arg1": True}, blocking=True
)
assert "Connection lost" in str(exc_info.value)
async def test_execute_service_connection_error_with_response(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test execute_service connection error when waiting for response."""
service = UserService(
name="error_service",
key=1,
args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)],
supports_response=SupportsResponseType.STATUS, # Uses response path
)
mock_client.execute_service = AsyncMock(
side_effect=APIConnectionError("Connection lost")
)
await mock_esphome_device(
mock_client=mock_client,
user_service=[service],
device_info={"name": "test"},
)
await hass.async_block_till_done()
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
DOMAIN, "test_error_service", {"arg1": True}, blocking=True
)
assert "Connection lost" in str(exc_info.value)
async def test_execute_service_failure_response(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test execute_service with failure response from device."""
service = UserService(
name="failing_service",
key=1,
args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)],
supports_response=SupportsResponseType.STATUS,
)
# Set up mock failure response
mock_client.execute_service = AsyncMock(
return_value=ExecuteServiceResponse(
call_id=1,
success=False,
error_message="Device reported error: invalid argument",
response_data=b"",
)
)
await mock_esphome_device(
mock_client=mock_client,
user_service=[service],
device_info={"name": "test"},
)
await hass.async_block_till_done()
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
DOMAIN, "test_failing_service", {"arg1": True}, blocking=True
)
assert "invalid argument" in str(exc_info.value)
async def test_execute_service_invalid_json_response(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test execute_service with invalid JSON in response data."""
service = UserService(
name="bad_json_service",
key=1,
args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)],
supports_response=SupportsResponseType.ONLY,
)
# Set up mock response with invalid JSON
mock_client.execute_service = AsyncMock(
return_value=ExecuteServiceResponse(
call_id=1,
success=True,
error_message="",
response_data=b"not valid json {{{",
)
)
await mock_esphome_device(
mock_client=mock_client,
user_service=[service],
device_info={"name": "test"},
)
await hass.async_block_till_done()
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
DOMAIN,
"test_bad_json_service",
{"arg1": True},
blocking=True,
return_response=True,
)
assert "Invalid JSON response" in str(exc_info.value)
async def test_service_registration_response_types(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test that services are registered with correct SupportsResponse types."""
services = [
UserService(
name="none_service",
key=1,
args=[],
supports_response=SupportsResponseType.NONE,
),
UserService(
name="optional_service",
key=2,
args=[],
supports_response=SupportsResponseType.OPTIONAL,
),
UserService(
name="only_service",
key=3,
args=[],
supports_response=SupportsResponseType.ONLY,
),
UserService(
name="status_service",
key=4,
args=[],
supports_response=SupportsResponseType.STATUS,
),
]
await mock_esphome_device(
mock_client=mock_client,
user_service=services,
device_info={"name": "test"},
)
await hass.async_block_till_done()
# Verify all services are registered
assert hass.services.has_service(DOMAIN, "test_none_service")
assert hass.services.has_service(DOMAIN, "test_optional_service")
assert hass.services.has_service(DOMAIN, "test_only_service")
assert hass.services.has_service(DOMAIN, "test_status_service")
# Verify response types are correctly mapped using public API
# NONE -> SupportsResponse.NONE
# OPTIONAL -> SupportsResponse.OPTIONAL
# ONLY -> SupportsResponse.ONLY
# STATUS -> SupportsResponse.NONE (no data returned to HA)
assert (
hass.services.supports_response(DOMAIN, "test_none_service")
== SupportsResponse.NONE
)
assert (
hass.services.supports_response(DOMAIN, "test_optional_service")
== SupportsResponse.OPTIONAL
)
assert (
hass.services.supports_response(DOMAIN, "test_only_service")
== SupportsResponse.ONLY
)
assert (
hass.services.supports_response(DOMAIN, "test_status_service")
== SupportsResponse.NONE
)

View File

@@ -59,6 +59,7 @@ MOCK_TRACKER = Tracker(
not_charging=True,
overall=True,
),
icon="http://res.cloudinary.com/iot-venture/image/upload/v1717594357/kyaqq7nfitrdvaoakb8s.jpg",
)

View File

@@ -37,6 +37,7 @@
# name: test_state_entity_device_snapshots[device_tracker.fluffy-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'entity_picture': 'http://res.cloudinary.com/iot-venture/image/upload/v1717594357/kyaqq7nfitrdvaoakb8s.jpg',
'friendly_name': 'Fluffy',
'gps_accuracy': 10.0,
'latitude': 52.520008,

View File

@@ -54,6 +54,57 @@
'state': '3.5',
})
# ---
# name: test_setup_and_update[packet_loss]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.10_10_10_10_packet_loss',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Packet loss',
'platform': 'ping',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'loss',
'unit_of_measurement': '%',
})
# ---
# name: test_setup_and_update[packet_loss].1
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': '10.10.10.10 Packet loss',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.10_10_10_10_packet_loss',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.0',
})
# ---
# name: test_setup_and_update[round_trip_time_average]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -17,6 +17,7 @@ from homeassistant.helpers import entity_registry as er
"round_trip_time_mean_deviation", # should be None in the snapshot
"round_trip_time_minimum",
"jitter",
"packet_loss",
],
)
async def test_setup_and_update(

View File

@@ -603,6 +603,8 @@ def _mock_blu_rtv_device(version: str | None = None):
}
),
xmod_info={},
wifi_setconfig=AsyncMock(return_value={}),
ble_setconfig=AsyncMock(return_value={}),
)
type(device).name = PropertyMock(return_value="Test name")
return device

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,7 @@
'device_id': <ANY>,
'disabled_by': None,
'domain': 'light',
'entity_category': None,
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'light.device_with_led_led',
'has_entity_name': True,
'hidden_by': None,

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from collections.abc import Callable
from collections.abc import Callable, Generator
from datetime import datetime, timedelta
from functools import partial
from ipaddress import IPv4Address
@@ -32,7 +32,15 @@ from uiprotect.data import (
from uiprotect.websocket import WebsocketState
from homeassistant.components.unifiprotect.const import DOMAIN
from homeassistant.const import CONF_API_KEY
from homeassistant.components.unifiprotect.utils import _async_unifi_mac_from_hass
from homeassistant.const import (
CONF_API_KEY,
CONF_HOST,
CONF_PASSWORD,
CONF_PORT,
CONF_USERNAME,
CONF_VERIFY_SSL,
)
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
@@ -43,6 +51,14 @@ from tests.common import MockConfigEntry, load_fixture
MAC_ADDR = "aa:bb:cc:dd:ee:ff"
# Common test data constants
DEFAULT_HOST = "1.1.1.1"
DEFAULT_PORT = 443
DEFAULT_VERIFY_SSL = False
DEFAULT_USERNAME = "test-username"
DEFAULT_PASSWORD = "test-password"
DEFAULT_API_KEY = "test-api-key"
@pytest.fixture(name="nvr")
def mock_nvr():
@@ -66,13 +82,13 @@ def mock_ufp_config_entry():
return MockConfigEntry(
domain=DOMAIN,
data={
"host": "1.1.1.1",
"username": "test-username",
"password": "test-password",
CONF_API_KEY: "test-api-key",
CONF_HOST: DEFAULT_HOST,
CONF_USERNAME: DEFAULT_USERNAME,
CONF_PASSWORD: DEFAULT_PASSWORD,
CONF_API_KEY: DEFAULT_API_KEY,
"id": "UnifiProtect",
"port": 443,
"verify_ssl": False,
CONF_PORT: DEFAULT_PORT,
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
},
version=2,
)
@@ -371,6 +387,78 @@ def fixed_now_fixture():
return dt_util.utcnow()
@pytest.fixture(name="ufp_reauth_entry")
def mock_ufp_reauth_entry():
"""Mock the unifiprotect config entry for reauth and reconfigure tests."""
return MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: DEFAULT_HOST,
CONF_USERNAME: DEFAULT_USERNAME,
CONF_PASSWORD: DEFAULT_PASSWORD,
CONF_API_KEY: DEFAULT_API_KEY,
"id": "UnifiProtect",
CONF_PORT: DEFAULT_PORT,
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
},
unique_id=_async_unifi_mac_from_hass(MAC_ADDR),
)
@pytest.fixture(name="ufp_reauth_entry_alt")
def mock_ufp_reauth_entry_alt():
"""Mock the unifiprotect config entry with alternate port/SSL for reauth/reconfigure tests."""
return MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: DEFAULT_HOST,
CONF_USERNAME: DEFAULT_USERNAME,
CONF_PASSWORD: DEFAULT_PASSWORD,
CONF_API_KEY: DEFAULT_API_KEY,
"id": "UnifiProtect",
CONF_PORT: 8443,
CONF_VERIFY_SSL: True,
},
unique_id=_async_unifi_mac_from_hass(MAC_ADDR),
)
@pytest.fixture(name="mock_setup")
def mock_setup_fixture() -> Generator[AsyncMock]:
"""Mock async_setup and async_setup_entry to prevent reload issues in tests."""
with (
patch(
"homeassistant.components.unifiprotect.async_setup",
return_value=True,
),
patch(
"homeassistant.components.unifiprotect.async_setup_entry",
return_value=True,
) as mock,
):
yield mock
@pytest.fixture(name="mock_api_bootstrap")
def mock_api_bootstrap_fixture(bootstrap: Bootstrap):
"""Mock the ProtectApiClient.get_bootstrap method."""
with patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap",
return_value=bootstrap,
) as mock:
yield mock
@pytest.fixture(name="mock_api_meta_info")
def mock_api_meta_info_fixture():
"""Mock the ProtectApiClient.get_meta_info method."""
with patch(
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info",
return_value=None,
) as mock:
yield mock
@pytest.fixture(name="cloud_account")
def cloud_account() -> CloudAccount:
"""Return UI Cloud Account."""

File diff suppressed because it is too large Load Diff