mirror of
https://github.com/home-assistant/core.git
synced 2025-12-07 16:38:07 +00:00
Compare commits
14 Commits
device_tra
...
shelly_ble
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0fd5cd2b2 | ||
|
|
2fd8ef2428 | ||
|
|
ec1880d803 | ||
|
|
319d6711c4 | ||
|
|
ea3f76c315 | ||
|
|
b892cc1cad | ||
|
|
3046c7afd8 | ||
|
|
73dc81034e | ||
|
|
f306cde3b6 | ||
|
|
38c5e483a8 | ||
|
|
ce14544ec1 | ||
|
|
87b9c3193e | ||
|
|
061c38d2a7 | ||
|
|
e1720be5a4 |
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
134
homeassistant/components/airobot/sensor.py
Normal file
134
homeassistant/components/airobot/sensor.py
Normal 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)
|
||||
@@ -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."
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
@@ -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}"
|
||||
},
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
9
homeassistant/components/ping/icons.json
Normal file
9
homeassistant/components/ping/icons.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"loss": {
|
||||
"default": "mdi:alert-circle-outline"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
"jitter": {
|
||||
"name": "Jitter"
|
||||
},
|
||||
"loss": {
|
||||
"name": "Packet loss"
|
||||
},
|
||||
"round_trip_time_avg": {
|
||||
"name": "Round-trip time average"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
4
requirements_all.txt
generated
@@ -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
|
||||
|
||||
4
requirements_test_all.txt
generated
4
requirements_test_all.txt
generated
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
220
tests/components/airobot/snapshots/test_sensor.ambr
Normal file
220
tests/components/airobot/snapshots/test_sensor.ambr
Normal 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',
|
||||
})
|
||||
# ---
|
||||
@@ -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)
|
||||
|
||||
38
tests/components/airobot/test_sensor.py
Normal file
38
tests/components/airobot/test_sensor.py
Normal 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
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -59,6 +59,7 @@ MOCK_TRACKER = Tracker(
|
||||
not_charging=True,
|
||||
overall=True,
|
||||
),
|
||||
icon="http://res.cloudinary.com/iot-venture/image/upload/v1717594357/kyaqq7nfitrdvaoakb8s.jpg",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user